Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions app-backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,37 @@ You can explore, test, and understand the structure of all API endpoints there.
- API docs with Swagger UI
- Fully containerized backend with Docker

## Shift Request API

Base path: `/api/v1/shift-requests`

| Method | Endpoint | Roles | Description |
| --- | --- | --- | --- |
| `POST` | `/` | Guard | Create a `SWAP` or `LEAVE` request for a shift assigned to the authenticated guard. |
| `GET` | `/` | Guard, Employer, Admin | List shift requests scoped to the authenticated user. Supports `status`, `type`, `page`, and `limit` query parameters. |
| `GET` | `/:id` | Guard, Employer, Admin | Fetch one shift request when it is in the authenticated user scope. |
| `PATCH` | `/:id` | Employer, Admin | Approve or reject a pending shift request with `{ "status": "APPROVED" }` or `{ "status": "REJECTED", "rejectionReason": "..." }`. |

### Roles and scoping

- Guards can create requests only for shifts they are assigned to through `guardIds` or `acceptedBy`.
- Guards can list and view only their own submitted shift requests.
- Employers can list, view, approve, or reject requests for shifts they created or shifts attached to active branches they own.
- Admins can list, view, approve, or reject shift requests across the system.

### Status transitions

Shift requests start as `PENDING`. The only allowed terminal transitions are:

- `PENDING -> APPROVED`
- `PENDING -> REJECTED`

Terminal requests cannot transition again.

### Approval limitation

Approving or rejecting a shift request currently records the decision on the `ShiftRequest` only. It does not alter shift assignment, guard rosters, availability, notifications, replacement-shift ownership, or target-guard state. Any roster-changing workflow requires separate product sign-off and implementation.

---

## 🧪 Testing
Expand Down
79 changes: 79 additions & 0 deletions app-backend/src/controllers/shiftrequest.controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import {
createShiftRequest as createShiftRequestService,
getShiftRequestForUser,
listShiftRequestsForUser,
reviewShiftRequest,
} from '../services/shiftrequest.service.js';

const handleError = (res, error) => {
const statusCode = error.statusCode || 500;
return res.status(statusCode).json({ message: error.message || 'Shift request operation failed' });
};

export const createShiftRequest = async (req, res) => {
try {
const shiftRequest = await createShiftRequestService({
user: req.user,
payload: req.body,
});

return res.status(201).json({
success: true,
data: shiftRequest,
message: 'Shift request created',
});
} catch (error) {
return handleError(res, error);
}
};

export const getShiftRequests = async (req, res) => {
try {
const result = await listShiftRequestsForUser({
user: req.user,
query: req.query,
});

return res.json({
success: true,
...result,
});
} catch (error) {
return handleError(res, error);
}
};

export const getShiftRequestById = async (req, res) => {
try {
const shiftRequest = await getShiftRequestForUser({
user: req.user,
requestId: req.params.id,
});

return res.json({
success: true,
data: shiftRequest,
});
} catch (error) {
return handleError(res, error);
}
};

export const updateShiftRequest = async (req, res) => {
try {
const shiftRequest = await reviewShiftRequest({
user: req.user,
requestId: req.params.id,
status: req.body.status,
rejectionReason: req.body.rejectionReason,
});

return res.json({
success: true,
data: shiftRequest,
message: `Shift request ${shiftRequest.status.toLowerCase()}`,
});
} catch (error) {
return handleError(res, error);
}
};
124 changes: 124 additions & 0 deletions app-backend/src/models/ShiftRequest.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import mongoose from 'mongoose';

const { Schema, model } = mongoose;

export const SHIFT_REQUEST_TYPES = ['SWAP', 'LEAVE'];
export const SHIFT_REQUEST_STATUSES = ['PENDING', 'APPROVED', 'REJECTED'];

const shiftRequestSchema = new Schema(
{
type: {
type: String,
enum: SHIFT_REQUEST_TYPES,
required: true,
index: true,
},
status: {
type: String,
enum: SHIFT_REQUEST_STATUSES,
default: 'PENDING',
required: true,
index: true,
},
requestingGuardId: {
type: Schema.Types.ObjectId,
ref: 'User',
required: true,
index: true,
},
targetGuardId: {
type: Schema.Types.ObjectId,
ref: 'User',
default: null,
validate: {
validator(value) {
return this.type !== 'SWAP' || Boolean(value);
},
message: 'Target guard is required for shift swap requests',
},
},
originalShiftId: {
type: Schema.Types.ObjectId,
ref: 'Shift',
required: true,
index: true,
},
replacementShiftId: {
type: Schema.Types.ObjectId,
ref: 'Shift',
default: null,
},
leaveStartDate: {
type: Date,
default: null,
validate: {
validator(value) {
return this.type !== 'LEAVE' || Boolean(value);
},
message: 'Leave start date is required for leave requests',
},
},
leaveEndDate: {
type: Date,
default: null,
validate: {
validator(value) {
if (this.type === 'LEAVE' && !value) return false;
return !value || !this.leaveStartDate || value >= this.leaveStartDate;
},
message: 'Leave end date must be on or after leave start date',
},
},
reason: {
type: String,
required: true,
trim: true,
minlength: 3,
maxlength: 1000,
},
rejectionReason: {
type: String,
trim: true,
maxlength: 500,
default: null,
},
reviewedBy: {
type: Schema.Types.ObjectId,
ref: 'User',
default: null,
},
reviewedAt: {
type: Date,
default: null,
},
},
{
timestamps: true,
toJSON: { virtuals: true },
toObject: { virtuals: true },
}
);

shiftRequestSchema.index({ requestingGuardId: 1, status: 1, createdAt: -1 });
shiftRequestSchema.index({ originalShiftId: 1, status: 1 });
shiftRequestSchema.index({ targetGuardId: 1, status: 1 });

shiftRequestSchema.virtual('isActionable').get(function () {
return this.status === 'PENDING';
});

shiftRequestSchema.pre('validate', function (next) {
if (this.type === 'LEAVE' && this.leaveStartDate) {
const today = new Date();
today.setHours(0, 0, 0, 0);

if (this.leaveStartDate < today) {
return next(new Error('Leave start date cannot be in the past'));
}
}

return next();
});

const ShiftRequest = model('ShiftRequest', shiftRequestSchema);
export default ShiftRequest;
4 changes: 3 additions & 1 deletion app-backend/src/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import equipmentRoutes from './equipment.routes.js';
import payrollRoutes from './payroll.routes.js';
import documentRoutes from './document.routes.js';
import emergencyRoutes from "./emergency.routes.js";
import shiftRequestRoutes from './shiftrequest.routes.js';
const router = express.Router();
router.use('/documents', documentRoutes);
router.use('/health', healthRoutes);
Expand All @@ -31,6 +32,7 @@ router.use('/attendance', shiftAttendanceRoutes);
router.use("/incidents", incidentRoutes);
router.use('/notifications', notificationRoutes);
router.use('/payroll', payrollRoutes);
router.use('/shift-requests', shiftRequestRoutes);
router.use('/equipment', equipmentRoutes);
router.use("/emergency", emergencyRoutes);
export default router;
export default router;
23 changes: 23 additions & 0 deletions app-backend/src/routes/shiftrequest.routes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import express from 'express';
import protect from '../middleware/auth.js';
import { authorizeRoles } from '../middleware/rbac.js';
import {
createShiftRequest,
getShiftRequestById,
getShiftRequests,
updateShiftRequest,
} from '../controllers/shiftrequest.controller.js';

const router = express.Router();

router
.route('/')
.post(protect, authorizeRoles('guard'), createShiftRequest)
.get(protect, authorizeRoles('guard', 'employer', 'admin'), getShiftRequests);

router
.route('/:id')
.get(protect, authorizeRoles('guard', 'employer', 'admin'), getShiftRequestById)
.patch(protect, authorizeRoles('employer', 'admin'), updateShiftRequest);

export default router;
Loading
Loading