Architecture
Architecture
Monorepo structure
shiftwise/
├── backend/ # Express API
├── frontend/ # React app
├── packages/
│ └── dates/ # Shared date utility package (@shiftwise/dates)
└── package.json # npm workspaces root
npm workspaces links all three packages together. Dependencies are hoisted
to the root node_modules — no duplication, no symlink issues.
Data model
The core schema is built around multi-tenant workspaces — a single deployment serves multiple organisations with full data isolation.
users → id, email, password_hash, name, timezone
workspaces → id, name, timezone
memberships → user_id, workspace_id, role (OWNER|MANAGER|EMPLOYEE)
refresh_tokens → id, token, user_id, expires_at
skills → id, workspace_id, name [unique: (workspace_id, name)]
membership_skills → membership_id, skill_id (join table)
shift_templates → id, workspace_id, name, start_time, end_time
forecast_slots → id, workspace_id, day_of_week, time, required
[unique: (workspace_id, day_of_week, time)]
availability → id, membership_id, day_of_week, start_time, end_time
[unique: (membership_id, day_of_week, start_time)]
Key design decisions:
- The
membershipstable with a composite unique key on(userId, workspaceId)enables multi-tenancy without duplicating user records. A user can belong to multiple workspaces with different roles in each. timezoneis stored on bothusersandworkspaces. User timezone takes priority — this handles managers in one city scheduling staff in another.refresh_tokensare stored in the database so they can be explicitly revoked on logout and rotated on each use.skillsare workspace-scoped and linked to employees via themembership_skillsjoin table — skills don’t follow employees between workspaces.forecast_slotsandavailabilityboth use composite unique keys to enable natural upsert semantics viaPUTendpoints — the client doesn’t need to know the record ID to update an existing slot.
Request lifecycle
Incoming request
│
├── cors() — check Origin header against FRONTEND_URL
├── express.json() — parse request body
├── cookieParser() — parse httpOnly cookies
├── requestLogger() — log method, path, status, duration
│
├── requireAuth() — verify Bearer token → attach full User to req.user
├── requireRole() — verify workspace membership → attach Membership to req.membership
│
├── Route handler — business logic, Prisma queries, response
│
└── Global error handler
├── AppError → { error, code } with correct HTTP status
└── Unknown → { error: message, code: INTERNAL_ERROR, stack? (dev only) }
Authentication flow
Register / Login
→ signAccessToken() → 15min JWT → returned in response body
→ signRefreshToken() → 7day JWT → set as httpOnly, Secure, SameSite=Strict cookie
Every API request
→ Authorization: Bearer <accessToken>
→ requireAuth() verifies token → attaches User to req.user
Access token expires (15min)
→ Axios interceptor catches 401
→ POST /api/auth/refresh (cookie sent automatically by browser)
→ verifyRefreshToken() validates cookie
→ Old token deleted, new tokens issued (rotation)
→ Original request retried with new access token
Logout
→ Refresh token deleted from DB
→ Cookie cleared
→ Zustand state cleared → redirect to /login
RBAC (Role-Based Access Control)
Two-layer system on every protected request:
| Layer | Middleware | Checks |
|---|---|---|
| Authentication | requireAuth |
Valid Bearer token, user exists |
| Authorization | requireRole(...roles) |
User is a workspace member with required role |
// Clean one-liner on every route
router.delete('/:workspaceId/employees/:userId',
requireAuth,
requireRole('OWNER', 'MANAGER'),
handler
)
requireRole attaches the full Membership + Workspace to req.membership
so route handlers never need an extra DB read to get workspace details.
Error handling
A custom AppError class with convenience factories replaces scattered
res.status().json() calls:
// Instead of:
res.status(403).json({ error: 'Insufficient permissions' })
// We throw:
throw Forbidden('Insufficient permissions')
All errors are caught by the global error handler which returns consistent
{ error, code } JSON. Unknown errors propagate the message always, but
only include the stack trace in development.
| Factory | Status | Code |
|---|---|---|
BadRequest() |
400 | BAD_REQUEST |
Unauthorized() |
401 | UNAUTHORIZED |
Forbidden() |
403 | FORBIDDEN |
NotFound() |
404 | NOT_FOUND |
Conflict() |
409 | CONFLICT |
Internal() |
500 | INTERNAL_ERROR |
Project structure (full)
shiftwise/
├── packages/
│ └── dates/
│ ├── package.json
│ ├── tsconfig.json
│ └── src/
│ ├── types.ts # Shared types
│ ├── utc.ts # UTC operations (BE + FE)
│ ├── display.ts # Localisation (FE only)
│ └── index.ts # Public facade
├── backend/
│ ├── prisma/
│ │ ├── schema.prisma # DB schema
│ │ └── seed.ts # Demo data
│ ├── src/
│ │ ├── lib/
│ │ │ ├── prisma.ts # Prisma singleton
│ │ │ ├── jwt.ts # Token signing/verification
│ │ │ ├── errors.ts # AppError class + convenience factories
│ │ │ └── responses.ts # Ok, Created, NoContent helpers
│ │ ├── middleware/
│ │ │ ├── auth.ts # requireAuth + requireRole middleware
│ │ │ └── logger.ts # Request logger with method, status, duration
│ │ ├── validation/
│ │ │ ├── auth.ts # RegisterSchema, LoginSchema
│ │ │ ├── workspaces.ts # AddEmployeeSchema
│ │ │ ├── skills.ts # CreateSkillSchema
│ │ │ ├── employeeSkills.ts # AddEmployeeSkillSchema
│ │ │ ├── shiftTemplates.ts # CreateShiftTemplateSchema
│ │ │ ├── forecast.ts # UpsertForecastSlotSchema
│ │ │ └── availability.ts # CreateAvailabilitySchema
│ │ ├── routes/
│ │ │ ├── auth.ts # Register, login, refresh, logout
│ │ │ ├── workspaces.ts # Employee CRUD
│ │ │ ├── skills.ts # Workspace skill CRUD
│ │ │ ├── employeeSkills.ts # Employee skill assignment
│ │ │ ├── shiftTemplates.ts # Shift template CRUD
│ │ │ ├── forecast.ts # Forecast slot upsert/delete
│ │ │ └── availability.ts # Employee availability upsert/delete
│ │ └── index.ts # Express app + global error handler
│ ├── src/tests/
│ │ ├── auth/
│ │ ├── workspaces/
│ │ ├── skills/ # skills.test.ts, employeeSkills.test.ts
│ │ ├── shiftTemplates/
│ │ ├── forecast/
│ │ ├── availability/
│ │ └── helpers.ts # buildApp(), fixtures, mock Prisma
│ ├── Dockerfile
│ ├── .dockerignore
│ └── .env.example
├── frontend/
│ ├── src/
│ │ ├── components/
│ │ │ ├── AddEmployeeModal.tsx
│ │ │ ├── NavBar.tsx # Shared nav with active-link highlighting
│ │ │ └── ProtectedRoute.tsx
│ │ ├── hooks/
│ │ │ ├── useEmployees.ts
│ │ │ ├── useSkills.ts
│ │ │ ├── useEmployeeSkills.ts
│ │ │ ├── useShiftTemplates.ts
│ │ │ ├── useForecast.ts
│ │ │ └── useAvailability.ts
│ │ ├── lib/
│ │ │ ├── api.ts # Axios + refresh interceptor
│ │ │ ├── store.ts # Zustand auth store
│ │ │ └── types.ts # Shared TS types (Employee, Skill, etc.)
│ │ ├── pages/
│ │ │ ├── LoginPage.tsx
│ │ │ ├── RegisterPage.tsx
│ │ │ ├── DashboardPage.tsx
│ │ │ ├── SkillsPage.tsx
│ │ │ ├── ShiftTemplatesPage.tsx
│ │ │ ├── ForecastPage.tsx
│ │ │ └── AvailabilityPage.tsx
│ │ ├── tests/
│ │ │ ├── msw/ # MSW handlers + server setup
│ │ │ └── pages/ # Vitest component tests
│ │ ├── App.tsx
│ │ └── main.tsx
│ ├── Dockerfile
│ ├── nginx.conf
│ ├── .dockerignore
│ └── vercel.json
├── docker-compose.yml
├── docker-compose.dev.yml
├── .env.example
├── .github/
│ └── workflows/
│ ├── ci.yml
│ └── deploy.yml
├── .devcontainer/
│ ├── devcontainer.json
│ └── setup.sh
└── package.json
Highlights
Database design: The memberships composite unique key enables multi-tenancy without duplicating user records. Foreign key cascades ensure referential integrity — deleting a workspace removes all memberships automatically.
Auth: Two separate JWT secrets mean a compromised refresh secret can’t forge access tokens. Refresh token rotation means a stolen token can only be used once before it’s invalidated.
Type safety end-to-end: Prisma generates TypeScript types from the schema, Zod validates all inputs at the boundary, and the frontend uses the same shapes via inferred types. A schema change propagates through the whole stack at compile time.
Upsert over create: Forecast slots and availability windows use PUT with
composite unique keys rather than POST — the client sends the natural key
(day + time or day + startTime) and the server creates or updates as needed.
This removes the need for the frontend to track record IDs for editing.
Middleware composition: requireAuth and requireRole are composable
middlewares that attach data to the request object — route handlers receive a
fully populated req.user and req.membership with zero extra DB reads.