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
63 changes: 62 additions & 1 deletion PROJECT_TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,4 +123,65 @@
- Backend and frontend production build: passed
- Phase 6 scheduling validation is intentionally deferred

Later: scheduling validation, leave, roster, then optional dashboard. V2 is out of scope.
## Phase 6 — Scheduling validation

- [x] Shift end time must be later than start time
- [x] Assigned shifts cannot overlap; adjacent shifts remain allowed
- [x] Assigned shifts must fit recurring availability
- [x] Approved leave blocks assignment for covered dates
- [x] Inactive employees cannot receive new or updated assignments
- [x] Scheduling rules live once in SchedulingService
- [x] Stable business-rule error codes flow through the Shift API

### Phase 6 verification (2026-09-30)

- Scheduling and related Shift targeted tests: 15 passed
- Full backend regression: 35 passed; live database test skipped
- Full frontend regression: 10 passed
- Backend and frontend typecheck: passed
- Backend and frontend ESLint: passed without warnings
- Backend and frontend production build: passed
- Live PostgreSQL query execution: not verified locally

## Phase 7 — Leave requests

- [x] Employee submits leave using their authenticated employee identity
- [x] Employee views only their own leave requests and statuses
- [x] Manager lists all leave requests
- [x] Manager approves or rejects pending requests
- [x] Invalid date ranges and repeated reviews are rejected
- [x] Role permissions and ownership are enforced
- [x] Approved leave remains integrated with scheduling validation
- [x] Employee and manager Leave UI workflows

### Phase 7 verification (2026-10-04)

- Leave and scheduling targeted backend tests: 14 passed
- Leave UI targeted tests: 2 passed
- Full backend regression: 43 passed; live database test skipped
- Full frontend regression: 12 passed
- Backend and frontend typecheck: passed
- Backend and frontend ESLint: passed without warnings
- Backend and frontend production build: passed
- Live PostgreSQL persistence: not verified locally

Later: weekly roster, then optional dashboard. V2 is out of scope.

## Phase 8 — Weekly roster

- [x] Manager views all non-cancelled shifts for a seven-day range
- [x] Employee views only their own non-cancelled weekly shifts
- [x] Server-derived employee scope and role permissions
- [x] Manager and employee weekly roster UI
- [x] Empty-week state and selectable week start

### Phase 8 verification (2026-10-04)

- Backend roster route tests added
- Full backend regression: 46 passed; live database test skipped
- Full frontend regression: 14 passed
- Backend and frontend typecheck: passed
- Backend and frontend ESLint: passed without warnings
- Backend and frontend production build: passed

Optional dashboard refinement remains. V2 is out of scope.
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Portfolio-quality workforce scheduling MVP for shift-based SMEs. Phase 1 provide

## Current scope

Phase 0 planning, Phase 1 scaffold, Phase 2 authentication/RBAC, Phase 3 employee management, Phase 4 recurring availability, and Phase 5 Shift CRUD are complete. Scheduling validation, leave, and roster workflows are deliberately deferred.
Phase 0 through Phase 8 are implemented, covering authentication, employee management, availability, Shift CRUD, scheduling validation, Leave, and weekly roster views.

## Quick start

Expand Down Expand Up @@ -40,6 +40,14 @@ Employees can view and replace their own seven-day recurring availability throug

## Shift management

Managers can list shifts by date range, create and edit shifts, and cancel them without deleting historical rows. Shift creation supports an optional employee assignment. Phase 6 will add assignment validation for overlaps, availability, approved leave, valid time ordering, and active employee status.
Managers can list shifts by date range, create and edit shifts, and cancel them without deleting historical rows. Shift creation supports an optional employee assignment. SchedulingService rejects invalid time ordering, overlapping assignments, shifts outside availability, approved-leave dates, and inactive employees. Adjacent shifts are allowed, and cancelled shifts do not block new assignments.

## Leave requests

Employees can submit date-ranged leave requests and view their own status. Managers can list all requests and approve or reject pending items. Employee ownership comes from the authenticated server session, invalid ranges are rejected, and reviewed requests cannot be reviewed twice. Approved leave blocks later shift assignment through SchedulingService.

## Weekly roster

Managers can view all non-cancelled shifts for a selected seven-day period. Employees receive a server-scoped weekly schedule containing only shifts assigned to their authenticated employee profile.

Architecture: React → REST → routes → controllers → services → repositories/Prisma → PostgreSQL. Authentication logic lives in AuthenticationService; business rules belong in services.
15 changes: 14 additions & 1 deletion backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,23 @@ import { PrismaAuthUserRepository } from './repositories/AuthUserRepository.js';
import { PrismaAvailabilityRepository } from './repositories/AvailabilityRepository.js';
import { PrismaEmployeeRepository } from './repositories/EmployeeRepository.js';
import { PrismaShiftRepository } from './repositories/ShiftRepository.js';
import { PrismaSchedulingRepository } from './repositories/SchedulingRepository.js';
import { PrismaLeaveRepository } from './repositories/LeaveRepository.js';
import { PrismaRosterRepository } from './repositories/RosterRepository.js';
import { createAvailabilityRouter, createManagerAvailabilityRouter } from './routes/availability.js';
import { createAuthRouter } from './routes/auth.js';
import { healthRouter } from './routes/health.js';
import { createEmployeeRouter } from './routes/employees.js';
import { createShiftRouter } from './routes/shifts.js';
import { createLeaveRouter } from './routes/leave.js';
import { createRosterRouter } from './routes/roster.js';
import { AuthenticationService } from './services/AuthenticationService.js';
import { AvailabilityService } from './services/AvailabilityService.js';
import { EmployeeService } from './services/EmployeeService.js';
import { ShiftService } from './services/ShiftService.js';
import { SchedulingService } from './services/SchedulingService.js';
import { LeaveService } from './services/LeaveService.js';
import { RosterService } from './services/RosterService.js';

const jwtSecret = process.env.JWT_SECRET ?? 'development-only-secret-change-me-now';
if (process.env.NODE_ENV === 'production' && !process.env.JWT_SECRET) {
Expand All @@ -21,7 +29,10 @@ if (process.env.NODE_ENV === 'production' && !process.env.JWT_SECRET) {
const authenticationService = new AuthenticationService(new PrismaAuthUserRepository(), jwtSecret);
const employeeService = new EmployeeService(new PrismaEmployeeRepository());
const availabilityService = new AvailabilityService(new PrismaAvailabilityRepository());
const shiftService = new ShiftService(new PrismaShiftRepository());
const schedulingService = new SchedulingService(new PrismaSchedulingRepository());
const shiftService = new ShiftService(new PrismaShiftRepository(), schedulingService);
const leaveService = new LeaveService(new PrismaLeaveRepository());
const rosterService = new RosterService(new PrismaRosterRepository());

export const app = express();
app.use(cors({ origin: process.env.FRONTEND_URL ?? 'http://localhost:5173' }));
Expand All @@ -32,3 +43,5 @@ app.use('/api/employees', createEmployeeRouter(authenticationService, employeeSe
app.use('/api/employees', createManagerAvailabilityRouter(authenticationService, availabilityService));
app.use('/api/availability', createAvailabilityRouter(authenticationService, availabilityService));
app.use('/api/shifts', createShiftRouter(authenticationService, shiftService));
app.use('/api/leave-requests', createLeaveRouter(authenticationService, leaveService));
app.use('/api/roster', createRosterRouter(authenticationService, rosterService));
2 changes: 2 additions & 0 deletions backend/src/repositories/LeaveRepository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import type { LeaveRepository } from '../services/LeaveService.js';import{prisma}from'./prisma.js';
export class PrismaLeaveRepository implements LeaveRepository {listForEmployee(employeeId:string){return prisma.leaveRequest.findMany({where:{employeeId},orderBy:{createdAt:'desc'}});}listAll(){return prisma.leaveRequest.findMany({orderBy:{createdAt:'desc'}});}findById(id:string){return prisma.leaveRequest.findUnique({where:{id}});}create(input:Parameters<LeaveRepository['create']>[0]){return prisma.leaveRequest.create({data:input});}review(id:string,status:'APPROVED'|'REJECTED',reviewedById:string){return prisma.leaveRequest.update({where:{id},data:{status,reviewedById,reviewedAt:new Date()}});}}
2 changes: 2 additions & 0 deletions backend/src/repositories/RosterRepository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import type{RosterRepository}from'../services/RosterService.js';import{prisma}from'./prisma.js';
export class PrismaRosterRepository implements RosterRepository{list(start:Date,end:Date,employeeId?:string){return prisma.shift.findMany({where:{date:{gte:start,lte:end},status:{not:'CANCELLED'},...(employeeId?{employeeId}:{})},orderBy:[{date:'asc'},{startTime:'asc'}]});}}
9 changes: 9 additions & 0 deletions backend/src/repositories/SchedulingRepository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { SchedulingRepository } from '../services/SchedulingService.js';
import { prisma } from './prisma.js';
const days=['SUNDAY','MONDAY','TUESDAY','WEDNESDAY','THURSDAY','FRIDAY','SATURDAY'] as const;
export class PrismaSchedulingRepository implements SchedulingRepository {
async getEmployeeStatus(employeeId:string){return(await prisma.employee.findUnique({where:{id:employeeId},select:{status:true}}))?.status??null;}
getAvailability(employeeId:string,dayOfWeek:number){return prisma.availability.findUnique({where:{employeeId_dayOfWeek:{employeeId,dayOfWeek:days[dayOfWeek]!}},select:{isAvailable:true,startTime:true,endTime:true}});}
async hasApprovedLeave(employeeId:string,date:Date){return(await prisma.leaveRequest.count({where:{employeeId,status:'APPROVED',startDate:{lte:date},endDate:{gte:date}}}))>0;}
async hasOverlap(employeeId:string,date:Date,startTime:string,endTime:string,excludeShiftId?:string){return(await prisma.shift.count({where:{employeeId,date,status:{not:'CANCELLED'},startTime:{lt:endTime},endTime:{gt:startTime},...(excludeShiftId?{id:{not:excludeShiftId}}:{})}}))>0;}
}
4 changes: 4 additions & 0 deletions backend/src/routes/leave.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import{Router,type Response}from'express';import{z}from'zod';import{authenticate,requireRole,type AuthenticatedRequest}from'../middleware/auth.js';import type{AuthenticationService}from'../services/AuthenticationService.js';import{LeaveError,type LeaveService}from'../services/LeaveService.js';
const date=/^\d{4}-\d{2}-\d{2}$/;const dateString=z.string().regex(date).refine(value=>{const parsed=new Date(`${value}T00:00:00.000Z`);return !Number.isNaN(parsed.getTime())&&parsed.toISOString().slice(0,10)===value;});const create=z.object({startDate:dateString,endDate:dateString,reason:z.string().trim().min(1)});const review=z.object({status:z.enum(['APPROVED','REJECTED'])});
const fail=(res:Response,error:LeaveError)=>res.status(error.code==='NOT_FOUND'?404:error.code==='LEAVE_ALREADY_REVIEWED'?409:400).json({error:{code:error.code,message:error.message}});async function handle(res:Response,action:()=>Promise<unknown>,status=200){try{res.status(status).json({leaveRequest:await action()});}catch(error){if(error instanceof LeaveError){fail(res,error);return;}throw error;}}
export function createLeaveRouter(auth:AuthenticationService,service:LeaveService){const router=Router();router.use(authenticate(auth));router.get('/me',requireRole('EMPLOYEE'),async(req,res)=>{const id=(req as AuthenticatedRequest).authUser?.employeeId;if(!id){res.status(403).json({error:{code:'FORBIDDEN',message:'An employee profile is required.'}});return;}res.json({leaveRequests:await service.listMine(id)});});router.post('/',requireRole('EMPLOYEE'),async(req,res)=>{const id=(req as AuthenticatedRequest).authUser?.employeeId;const parsed=create.safeParse(req.body);if(!id){res.status(403).json({error:{code:'FORBIDDEN',message:'An employee profile is required.'}});return;}if(!parsed.success){res.status(400).json({error:{code:'VALIDATION_ERROR',message:'Valid leave details are required.'}});return;}await handle(res,()=>service.submit(id,parsed.data),201);});router.get('/',requireRole('MANAGER'),async(_req,res)=>res.json({leaveRequests:await service.listAll()}));router.patch('/:id/status',requireRole('MANAGER'),async(req,res)=>{const parsed=review.safeParse(req.body);if(!parsed.success){res.status(400).json({error:{code:'VALIDATION_ERROR',message:'A valid review status is required.'}});return;}const user=(req as AuthenticatedRequest).authUser!;await handle(res,()=>service.review(String(req.params.id),parsed.data.status,user.id));});return router;}
2 changes: 2 additions & 0 deletions backend/src/routes/roster.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import{Router}from'express';import{z}from'zod';import{authenticate,requireRole,type AuthenticatedRequest}from'../middleware/auth.js';import type{AuthenticationService}from'../services/AuthenticationService.js';import type{RosterService}from'../services/RosterService.js';
const date=z.string().regex(/^\d{4}-\d{2}-\d{2}$/);export function createRosterRouter(auth:AuthenticationService,service:RosterService){const router=Router();router.use(authenticate(auth));router.get('/',requireRole('MANAGER'),async(req,res)=>{const parsed=date.safeParse(req.query.weekStart);if(!parsed.success){res.status(400).json({error:{code:'VALIDATION_ERROR',message:'A valid weekStart is required.'}});return;}res.json({shifts:await service.weekly(parsed.data)});});router.get('/me',requireRole('EMPLOYEE'),async(req,res)=>{const parsed=date.safeParse(req.query.weekStart);const employeeId=(req as AuthenticatedRequest).authUser?.employeeId;if(!parsed.success){res.status(400).json({error:{code:'VALIDATION_ERROR',message:'A valid weekStart is required.'}});return;}if(!employeeId){res.status(403).json({error:{code:'FORBIDDEN',message:'An employee profile is required.'}});return;}res.json({shifts:await service.weekly(parsed.data,employeeId)});});return router;}
5 changes: 3 additions & 2 deletions backend/src/routes/shifts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@ import { z } from 'zod';
import { authenticate,requireRole,type AuthenticatedRequest } from '../middleware/auth.js';
import type { AuthenticationService } from '../services/AuthenticationService.js';
import { ShiftError,type ShiftService } from '../services/ShiftService.js';
import { SchedulingError } from '../services/SchedulingService.js';
const date=/^\d{4}-\d{2}-\d{2}$/;const time=/^([01]\d|2[0-3]):[0-5]\d$/;
const dateString=z.string().regex(date).refine(value=>{const parsed=new Date(`${value}T00:00:00.000Z`);return !Number.isNaN(parsed.getTime())&&parsed.toISOString().slice(0,10)===value;});
const createSchema=z.object({employeeId:z.string().min(1).nullable().optional(),date:dateString,startTime:z.string().regex(time),endTime:z.string().regex(time),role:z.string().trim().min(1),notes:z.string().trim().nullable().optional(),status:z.enum(['DRAFT','SCHEDULED']).optional()});
const updateSchema=createSchema.partial();const rangeSchema=z.object({startDate:dateString,endDate:dateString}).refine(value=>value.startDate<=value.endDate);
const invalid=(response:Response)=>response.status(400).json({error:{code:'VALIDATION_ERROR',message:'Valid shift details are required.'}});
async function handle(response:Response,action:()=>Promise<unknown>){try{response.json({shift:await action()});}catch(error){if(error instanceof ShiftError){response.status(404).json({error:{code:error.code,message:error.message}});return;}throw error;}}
async function handle(response:Response,action:()=>Promise<unknown>,successStatus=200){try{response.status(successStatus).json({shift:await action()});}catch(error){if(error instanceof ShiftError){response.status(404).json({error:{code:error.code,message:error.message}});return;}if(error instanceof SchedulingError){const status=error.code==='INVALID_SHIFT_TIME'?400:error.code==='EMPLOYEE_NOT_FOUND'?404:409;response.status(status).json({error:{code:error.code,message:error.message}});return;}throw error;}}
export function createShiftRouter(auth:AuthenticationService,service:ShiftService){const router=Router();router.use(authenticate(auth),requireRole('MANAGER'));
router.get('/',async(request,response)=>{const parsed=rangeSchema.safeParse(request.query);if(!parsed.success){invalid(response);return;}response.json({shifts:await service.list(parsed.data.startDate,parsed.data.endDate)});});
router.post('/',async(request,response)=>{const parsed=createSchema.safeParse(request.body);if(!parsed.success){invalid(response);return;}const user=(request as AuthenticatedRequest).authUser!;response.status(201).json({shift:await service.create(user.id,parsed.data)});});
router.post('/',async(request,response)=>{const parsed=createSchema.safeParse(request.body);if(!parsed.success){invalid(response);return;}const user=(request as AuthenticatedRequest).authUser!;await handle(response,()=>service.create(user.id,parsed.data),201);});
router.patch('/:id',async(request,response)=>{const parsed=updateSchema.safeParse(request.body);if(!parsed.success){invalid(response);return;}await handle(response,()=>service.update(request.params.id!,parsed.data));});
router.delete('/:id',async(request,response)=>{await handle(response,()=>service.cancel(request.params.id!));});return router;}
6 changes: 6 additions & 0 deletions backend/src/services/LeaveService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export type LeaveStatus='PENDING'|'APPROVED'|'REJECTED';
export type LeaveRequest={id:string;employeeId:string;startDate:Date;endDate:Date;reason:string;status:LeaveStatus;reviewedById:string|null;reviewedAt:Date|null;createdAt:Date;updatedAt:Date};
export interface LeaveRepository {listForEmployee(employeeId:string):Promise<LeaveRequest[]>;listAll():Promise<LeaveRequest[]>;findById(id:string):Promise<LeaveRequest|null>;create(input:{employeeId:string;startDate:Date;endDate:Date;reason:string}):Promise<LeaveRequest>;review(id:string,status:'APPROVED'|'REJECTED',reviewedById:string):Promise<LeaveRequest>}
export class LeaveError extends Error {constructor(public readonly code:'INVALID_LEAVE_RANGE'|'NOT_FOUND'|'LEAVE_ALREADY_REVIEWED',message:string){super(message)}}
const date=(value:string)=>new Date(`${value}T00:00:00.000Z`);
export class LeaveService {constructor(private readonly leave:LeaveRepository){}listMine(employeeId:string){return this.leave.listForEmployee(employeeId);}listAll(){return this.leave.listAll();}async submit(employeeId:string,input:{startDate:string;endDate:string;reason:string}){if(input.startDate>input.endDate)throw new LeaveError('INVALID_LEAVE_RANGE','Leave start date cannot follow end date.');return this.leave.create({employeeId,startDate:date(input.startDate),endDate:date(input.endDate),reason:input.reason});}async review(id:string,status:'APPROVED'|'REJECTED',reviewedById:string){const request=await this.leave.findById(id);if(!request)throw new LeaveError('NOT_FOUND','Leave request not found.');if(request.status!=='PENDING')throw new LeaveError('LEAVE_ALREADY_REVIEWED','Leave request has already been reviewed.');return this.leave.review(id,status,reviewedById);}}
3 changes: 3 additions & 0 deletions backend/src/services/RosterService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import type{Shift}from'./ShiftService.js';
export interface RosterRepository{list(start:Date,end:Date,employeeId?:string):Promise<Shift[]>}
export class RosterService{constructor(private readonly roster:RosterRepository){}weekly(weekStart:string,employeeId?:string){const start=new Date(`${weekStart}T00:00:00.000Z`);const end=new Date(start);end.setUTCDate(end.getUTCDate()+6);return this.roster.list(start,end,employeeId);}}
Loading
Loading