Relay is a multi-tenant, event-driven workforce management platform. Every mutation writes to a canonical event log; database state is derived from events; audit logs are event log queries; socket events are event broadcasts.
┌─────────────────────────────────────────────────────────┐
│ Frontend (Next.js 14 + TypeScript) │
│ App Router pages • TanStack Query • Socket.io client │
│ Clerk auth • Tailwind CSS • Recharts │
└──────────────────────┬──────────────────────────────────┘
│ HTTP/REST + Socket.io
┌──────────────────────▼──────────────────────────────────┐
│ Backend (Node.js + Express) │
│ Clerk JWT verification • RBAC middleware │
│ Route handlers • Event emitter • Audit logger │
│ Socket.io server • Rate limiting • Helmet │
└──────────────────────┬──────────────────────────────────┘
│ pg connection pool
┌──────────────────────▼──────────────────────────────────┐
│ PostgreSQL (Neon serverless) │
│ Core tables • events (append-only) │
│ audit_logs (append-only) • payroll_snapshots │
│ DB triggers for immutability │
└─────────────────────────────────────────────────────────┘
relay/
├── backend/
│ ├── src/
│ │ ├── index.ts # Express server, middleware chain
│ │ ├── db/
│ │ │ ├── client.ts # PostgreSQL pool (max 20 connections)
│ │ │ ├── setup.ts # Schema creation + triggers
│ │ │ └── seed.ts # Demo account seeding
│ │ ├── routes/
│ │ │ ├── organisations.ts # Org settings, announcements, currency
│ │ │ ├── members.ts # Team CRUD, onboarding, availability
│ │ │ ├── shifts.ts # Shift CRUD + swap requests
│ │ │ ├── attendance.ts # Clock in/out + timesheets
│ │ │ ├── payroll.ts # Pay periods, processing, employee rates
│ │ │ ├── payslips.ts # Payslip listing + PDF generation
│ │ │ ├── overtime.ts # OT rule management
│ │ │ ├── messages.ts # Direct messaging (AES-256-GCM)
│ │ │ ├── notifications.ts # Per-user notifications
│ │ │ ├── analytics.ts # Workforce KPIs
│ │ │ ├── audit.ts # Audit log queries
│ │ │ ├── events.ts # Event feed for reconnect recovery
│ │ │ └── dev.ts # Demo access + seed/reset routes
│ │ ├── middleware/
│ │ │ ├── auth.ts # Clerk JWT verification + role guards
│ │ │ └── rateLimit.ts # Per-user sliding-window rate limiter
│ │ ├── lib/
│ │ │ ├── audit.ts # Audit logging utility
│ │ │ ├── payrollCalculations.ts # OT math (daily/weekly thresholds)
│ │ │ ├── events.ts # Event type constants
│ │ │ ├── eventEmitter.ts # Transactional event emission
│ │ │ ├── shiftConflicts.ts # SQL overlap detection
│ │ │ └── encryption.ts # AES-256-GCM encrypt/decrypt
│ │ ├── services/
│ │ │ ├── attendanceService.ts # Clock transactions + timesheet aggregation
│ │ │ ├── payrollService.ts # Payroll queries + processing logic
│ │ │ ├── shiftService.ts # Shift workflows + conflict helpers
│ │ │ └── jobQueueService.ts # Durable background jobs + pg-boss publishing
│ │ ├── scripts/
│ │ │ ├── check-demo-env.ts # Demo env validation
│ │ │ ├── run-manager-flow.ts # Local scripted manager workflow
│ │ │ └── seed-demo-scenario.ts # Rich Northstar logistics scenario
│ │ └── socket/
│ │ └── index.ts # Socket.io room management (org/user)
│ ├── workers/
│ │ └── queueWorker.ts # pg-boss worker runtime
│ └── test/
│ ├── helpers/
│ │ ├── http.js # In-process router test harness
│ │ └── moduleMocks.js # Dependency mocking for routes
│ ├── payroll-calculations.test.js
│ ├── shifts.integration.test.js
│ ├── crossOrg.integration.test.js
│ └── ...
│
├── frontend/
│ ├── src/
│ │ ├── app/
│ │ │ ├── layout.tsx # Root layout (ClerkProvider, Toaster)
│ │ │ ├── page.tsx # Landing page (WebGL hero)
│ │ │ ├── error.tsx # Global error boundary
│ │ │ ├── (auth)/ # Sign-in, sign-up pages
│ │ │ ├── dashboard/ # KPIs, announcements, upcoming shifts
│ │ │ ├── schedule/ # Searchable roster board with internal column scrolling
│ │ │ ├── team/ # Member management + role editing
│ │ │ ├── attendance/ # Clock in/out + timesheets
│ │ │ ├── payroll/ # Pay periods, processing, payslips
│ │ │ ├── messages/ # Direct message conversations
│ │ │ ├── analytics/ # Admin KPI dashboard + charts
│ │ │ ├── audit/ # Audit log with filters + pagination
│ │ │ ├── invite/ # Organisation registry + share ID
│ │ │ ├── onboarding/ # Create or join organisation
│ │ │ └── demo-access/ # Demo account selection
│ │ ├── components/
│ │ │ └── layout/
│ │ │ ├── AppLayout.tsx # Shared layout (sidebar + mobile header)
│ │ │ └── Sidebar.tsx # Navigation with role-based visibility
│ │ ├── hooks/
│ │ │ ├── useApi.ts # Axios instance with Clerk token
│ │ │ └── useSocket.ts # Socket.io with reconnect recovery
│ │ ├── types/
│ │ │ └── index.ts # Shared TypeScript interfaces
│ │ └── lib/
│ │ ├── env.ts # Trailing-slash-safe API/socket URL helpers
│ │ └── utils.ts # Helpers (initials, formatters, colors)
│ ├── scripts/
│ │ └── verify-ux.mjs # Playwright browser verification for public/demo flows
│ ├── middleware.ts # Clerk route protection
│ └── tailwind.config.js
│
├── .github/workflows/ci.yml # Lint, typecheck, test, build
├── README.md
├── APP_DOCUMENTATION.md
└── DEVELOPER_CONCEPTS_GUIDE.md
organisations
| Column | Type | Notes |
|---|---|---|
| id | UUID | Primary key |
| name | TEXT | Organisation name |
| currency | TEXT | ISO 4217 code (default: USD) |
| settings | JSONB | Org-level configuration |
| created_at | TIMESTAMPTZ |
members
| Column | Type | Notes |
|---|---|---|
| id | UUID | Primary key |
| organisation_id | UUID | FK → organisations |
| clerk_user_id | TEXT | Unique Clerk identifier |
| name | TEXT | Display name |
| TEXT | ||
| role | TEXT | ADMIN, MANAGER, or EMPLOYEE |
| hourly_rate | NUMERIC | Base hourly rate |
| can_manage_rates | BOOLEAN | Manager rate editing permission |
| created_at | TIMESTAMPTZ |
shifts
| Column | Type | Notes |
|---|---|---|
| id | UUID | Primary key |
| organisation_id | UUID | FK → organisations |
| title | TEXT | |
| start_time | TIMESTAMPTZ | |
| end_time | TIMESTAMPTZ | |
| location | TEXT | |
| notes | TEXT | |
| color | TEXT | Hex colour tag |
| status | TEXT | OPEN, ASSIGNED, IN_PROGRESS, COMPLETED |
| assignee_id | UUID | FK → members (nullable) |
| version | INTEGER | Optimistic locking |
| created_at | TIMESTAMPTZ | |
| updated_at | TIMESTAMPTZ |
clock_events
| Column | Type | Notes |
|---|---|---|
| id | UUID | Primary key |
| shift_id | UUID | FK → shifts |
| member_id | UUID | FK → members |
| type | TEXT | CLOCK_IN or CLOCK_OUT |
| timestamp | TIMESTAMPTZ | |
| latitude | NUMERIC | Optional location |
| longitude | NUMERIC | Optional location |
swap_requests
| Column | Type | Notes |
|---|---|---|
| id | UUID | Primary key |
| shift_id | UUID | FK → shifts |
| requester_id | UUID | FK → members |
| target_id | UUID | FK → members (nullable) |
| reason | TEXT | |
| status | TEXT | PENDING, APPROVED, REJECTED |
| created_at | TIMESTAMPTZ | |
| updated_at | TIMESTAMPTZ |
pay_periods
| Column | Type | Notes |
|---|---|---|
| id | UUID | Primary key |
| organisation_id | UUID | FK → organisations |
| period_type | TEXT | WEEKLY, BIWEEKLY, SEMI_MONTHLY, MONTHLY |
| start_date | DATE | |
| end_date | DATE | |
| status | TEXT | DRAFT, PROCESSED, PAID |
| processed_at | TIMESTAMPTZ |
payslips
| Column | Type | Notes |
|---|---|---|
| id | UUID | Primary key |
| member_id | UUID | FK → members |
| pay_period_id | UUID | FK → pay_periods |
| organisation_id | UUID | FK → organisations |
| base_hours | NUMERIC | |
| overtime_hours | NUMERIC | |
| overtime_rate | NUMERIC | Multiplier applied |
| base_earnings | NUMERIC | |
| overtime_earnings | NUMERIC | |
| total_earnings | NUMERIC | |
| currency | TEXT | |
| status | TEXT | DRAFT, PROCESSED, DOWNLOADED, PAID |
| generated_by | UUID | FK → members |
| created_at | TIMESTAMPTZ |
payroll_snapshots
| Column | Type | Notes |
|---|---|---|
| id | UUID | Primary key |
| pay_period_id | UUID | FK → pay_periods |
| organisation_id | UUID | FK → organisations |
| member_id | UUID | FK → members |
| hourly_rate | NUMERIC | Frozen at processing time |
| effective_rate_id | UUID | FK → employee_rates (if override) |
| overtime_multiplier | NUMERIC | Frozen at processing time |
| rule_id | UUID | FK → overtime_rules |
| rule_daily_threshold_hours | NUMERIC | Frozen |
| rule_weekly_threshold_hours | NUMERIC | Frozen |
| rule_daily_multiplier | NUMERIC | Frozen |
| rule_weekly_multiplier | NUMERIC | Frozen |
| total_hours | NUMERIC | |
| base_hours | NUMERIC | |
| overtime_hours | NUMERIC | |
| base_earnings | NUMERIC | |
| overtime_earnings | NUMERIC | |
| total_earnings | NUMERIC | |
| generated_by | UUID | FK → members |
| created_at | TIMESTAMPTZ |
overtime_rules
| Column | Type | Notes |
|---|---|---|
| id | UUID | Primary key |
| organisation_id | UUID | FK → organisations |
| name | TEXT | |
| daily_threshold_hours | NUMERIC | Default: 8 |
| weekly_threshold_hours | NUMERIC | Default: 40 |
| daily_multiplier | NUMERIC | Default: 1.5 |
| weekly_multiplier | NUMERIC | Default: 1.5 |
| is_active | BOOLEAN | |
| created_at | TIMESTAMPTZ |
employee_rates
| Column | Type | Notes |
|---|---|---|
| id | UUID | Primary key |
| member_id | UUID | FK → members |
| hourly_rate | NUMERIC | |
| overtime_multiplier | NUMERIC | |
| effective_from | DATE | |
| created_at | TIMESTAMPTZ |
announcements
| Column | Type | Notes |
|---|---|---|
| id | UUID | Primary key |
| title | TEXT | |
| content | TEXT | |
| priority | TEXT | NORMAL, HIGH, URGENT |
| organisation_id | UUID | FK → organisations |
| author_id | UUID | FK → members |
| target_member_id | UUID | FK → members (nullable) |
| created_at | TIMESTAMPTZ |
messages
| Column | Type | Notes |
|---|---|---|
| id | UUID | Primary key |
| sender_id | UUID | FK → members |
| receiver_id | UUID | FK → members |
| content | TEXT | AES-256-GCM encrypted |
| read | BOOLEAN | |
| created_at | TIMESTAMPTZ |
notifications
| Column | Type | Notes |
|---|---|---|
| id | UUID | Primary key |
| member_id | UUID | FK → members |
| type | TEXT | SHIFT_ASSIGNED, SHIFT_CANCELLED, etc. |
| title | TEXT | |
| body | TEXT | |
| read | BOOLEAN | |
| data | JSONB | |
| created_at | TIMESTAMPTZ |
audit_logs (append-only via DB trigger)
| Column | Type | Notes |
|---|---|---|
| id | UUID | Primary key |
| organisation_id | UUID | |
| member_id | UUID | FK → members |
| clerk_user_id | TEXT | |
| action | TEXT | CREATE, UPDATE, DELETE, CLOCK_IN, etc. |
| entity_type | TEXT | shift, member, pay_period, etc. |
| entity_id | UUID | |
| old_values | JSONB | Before state (UPDATE/DELETE) |
| new_values | JSONB | After state (CREATE/UPDATE) |
| ip_address | TEXT | |
| user_agent | TEXT | |
| created_at | TIMESTAMPTZ |
events (append-only via DB trigger)
| Column | Type | Notes |
|---|---|---|
| id | UUID | Primary key |
| seq | BIGSERIAL | Monotonic ordering |
| organisation_id | UUID | |
| member_id | UUID | FK → members |
| event_type | TEXT | shift.created, attendance.clock_in, etc. |
| entity_type | TEXT | |
| entity_id | UUID | |
| payload | JSONB | |
| ip_address | TEXT | |
| user_agent | TEXT | |
| created_at | TIMESTAMPTZ |
availability
| Column | Type | Notes |
|---|---|---|
| id | UUID | Primary key |
| member_id | UUID | FK → members |
| day_of_week | INTEGER | 0-6 |
| start_time | TIME | |
| end_time | TIME |
-- Blocks UPDATE and DELETE on events table
CREATE OR REPLACE FUNCTION block_events_modification()
RETURNS TRIGGER AS $$
BEGIN
RAISE EXCEPTION 'events table is append-only: UPDATE and DELETE are not permitted';
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER block_events_delete
BEFORE DELETE ON events
FOR EACH ROW EXECUTE FUNCTION block_events_modification();
CREATE TRIGGER block_events_update
BEFORE UPDATE ON events
FOR EACH ROW EXECUTE FUNCTION block_events_modification();
-- Same pattern for audit_logs- Frontend: Clerk manages sessions via
@clerk/nextjs - Route protection:
middleware.tscallsauth().protect()for non-public routes - API requests: Clerk token sent as
Authorization: Bearer <token> - Backend verification:
requireAuthmiddleware verifies JWT via@clerk/backend - Member sync: On first sign-in,
/onboardcreates member record - Socket auth: Token verified on handshake before room joins
In development (NODE_ENV=development), requests with x-dev-clerk-user-id header and Host: localhost:* skip JWT verification. Used for local testing and demo access.
helmet (security headers)
→ cors (origin restriction)
→ express.json (1mb limit)
→ io injection (req.io = socket server)
→ routes (each route has requireAuth + requireRole)
→ apiLimiter (global rate limit, admin bypass)
→ error handler (catches unhandled errors)
| Method | Path | Access | Description |
|---|---|---|---|
| GET | /api/shifts |
All | List shifts (org-scoped, optional date/assignee filters) |
| GET | /api/shifts/:id |
All | Get shift with clock events and swap requests |
| POST | /api/shifts |
Manager+ | Create shift (validates title, times, conflict check) |
| PUT | /api/shifts/:id |
Manager+ | Update shift (locks after clock-in, conflict check) |
| DELETE | /api/shifts/:id |
Manager+ | Delete shift (emits event, notifies assignee) |
| POST | /api/shifts/:id/swap |
All | Request shift swap |
| PATCH | /api/shifts/:id/swap/:swapId |
Manager+ | Approve/reject swap (org-scoped verification) |
| GET | /api/shifts/swaps/pending |
Manager+ | List pending swaps |
| Method | Path | Access | Description |
|---|---|---|---|
| POST | /api/attendance/clock-in |
All | Clock into assigned shift (transaction with FOR UPDATE lock) |
| POST | /api/attendance/clock-out |
All | Clock out (triggers OT alert if >8h) |
| GET | /api/attendance/live |
Manager+ | Currently clocked-in members |
| GET | /api/attendance/timesheet/me |
All | Personal completed shifts + hours |
| GET | /api/attendance/timesheet |
Manager+ | Organisation-wide completed shifts |
| Method | Path | Access | Description |
|---|---|---|---|
| GET | /api/payroll/pay-periods |
Manager+ | List pay periods with cost summary |
| POST | /api/payroll/pay-periods |
Manager+ | Create pay period |
| GET | /api/payroll/pay-periods/:id/timesheet |
All | Employee-by-employee timesheet with OT calc |
| GET | /api/payroll/pay-periods/:id/summary |
All | Summary: employee count, base/OT earnings, total cost |
| POST | /api/payroll/pay-periods/:id/process |
Admin | Generate payslips + snapshots (idempotent) |
| POST | /api/payroll/pay-periods/:id/paid |
Admin | Mark period as paid |
| DELETE | /api/payroll/pay-periods/:id/payslips |
Admin | Delete payslips/snapshots, reset to DRAFT |
| GET | /api/payroll/employee-rates |
Manager+ | Get rate override for member (org-scoped) |
| POST | /api/payroll/employee-rates |
Admin | Set rate override (org-scoped) |
| Method | Path | Access | Description |
|---|---|---|---|
| GET | /api/members |
All | List organisation members |
| POST | /api/members/onboard |
All | Create account + join/create org |
| GET | /api/members/me |
All | My profile |
| PUT | /api/members/me |
All | Update own profile |
| PATCH | /api/members/:id |
Admin | Update member role/rate |
| DELETE | /api/members/:id |
Admin | Remove member |
| GET | /api/organisations/me |
All | My org details |
| GET | /api/organisations/announcements |
All | List announcements |
| POST | /api/organisations/announcements |
Admin | Post announcement (broadcast or targeted) |
| DELETE | /api/organisations/announcements/:id |
Admin | Delete announcement |
| PUT | /api/organisations/currency |
Admin | Update org currency |
| GET | /api/messages |
All | Conversation with specific member |
| POST | /api/messages |
All | Send message (org-scoped, encrypted) |
| GET | /api/notifications |
All | My notifications |
| PATCH | /api/notifications/:id/read |
All | Mark as read |
| POST | /api/notifications/read-all |
All | Mark all as read |
| GET | /api/analytics |
Admin | Workforce KPIs + shift distribution |
| GET | /api/audit-logs |
Manager+ | Audit trail (filterable, paginated) |
| GET | /api/overtime |
Manager+ | List OT rules |
| POST | /api/overtime |
Manager+ | Create OT rule |
| PUT | /api/overtime/:id |
Manager+ | Update OT rule |
| DELETE | /api/overtime/:id |
Manager+ | Delete OT rule |
| GET | /api/events/since |
All | Event replay (cursor: ISO timestamp) |
| GET | /api/payslips |
All | My payslips |
| GET | /api/payslips/:id/pdf |
All | Download PDF payslip |
- Verify period is DRAFT (or return cached results if already processed)
- Fetch active overtime rule and employee rate overrides
- For each employee:
- Fetch completed shifts within period date range
- Calculate daily hours from clock-in/clock-out pairs
- Apply overtime:
max(dailyOT, weeklyOT)where dailyOT = sum of hours exceeding daily threshold per day - Calculate earnings:
baseHours * rate + overtimeHours * rate * multiplier - Insert
payroll_snapshot(frozen rates, rules, calculations) - Insert
payslip
- Update period status to PROCESSED
- Emit
pay_period.processedevent
SELECT id FROM shifts WHERE assignee_id = $1
AND status IN ('ASSIGNED', 'IN_PROGRESS')
AND (
(start_time <= $2 AND end_time > $2) OR
(start_time < $3 AND end_time >= $3) OR
(start_time >= $2 AND end_time <= $3)
)Prevents double-booking at creation and update time.
Once a shift has a CLOCK_IN event, PUT requests reject changes to startTime, endTime, and assigneeId with 409 SHIFT_LOCKED_AFTER_CLOCK_IN.
All write operations emit events. When inside a transaction, emitEvent({ client, ... }) writes the event within the same transaction for atomicity.
Event types: shift.created/updated/deleted/assigned, attendance.clock_in/clock_out, swap.requested/approved/rejected, pay_period.created/processed/paid, member.joined/role_changed, overtime_rule.created/updated/deleted, message.sent, announcement.created/deleted
org:${organisationId}— org-wide broadcasts (shift updates, announcements, swap requests)user:${memberId}— private notifications (messages, shift assignments)
- Client stores
lastEventTimestampin localStorage on every non-system event - On reconnect: rejoin org/user rooms, emit
connectedto get server time - Fetch
/api/events/since?since=lastEventTimestampfor missed events - Dispatch
SOCKET_RESYNC_EVENTcustom event to trigger page refresh - Update
lastEventTimestampbaseline
| Layer | Implementation |
|---|---|
| Authentication | Clerk JWT verification on every API request and socket handshake |
| Authorization | RBAC middleware (requireAuth, requireRole) on all protected routes |
| Multi-tenant | Every query filtered by organisation_id |
| Headers | Helmet.js: CSP, HSTS (1yr), frame-ancestors: none, XSS filter, strict referrer |
| CORS | Restricted to FRONTEND_URL plus optional FRONTEND_URLS origins |
| Rate limiting | Global 1000 req/15min per IP (admin bypass) + per-user sliding window on sensitive routes |
| Input validation | express-validator on POST/PUT/PATCH (trim, escape, UUID check, enum check) |
| SQL injection | Parameterized queries only |
| Encryption | AES-256-GCM for message content at rest |
| Audit | All write operations logged; events and audit_logs are append-only via DB triggers |
| Proxy support | trust proxy enabled for correct client IP behind reverse proxies |
- Backend: Node.js built-in test runner (
node --test) for payroll math and in-process route integration tests with mocked dependencies - Frontend: Vitest + React Testing Library for utilities and component rendering
- Browser verification:
cd frontend && npm run verify:uxfor Playwright screenshots and console-error checks on the core public/demo routes - Coverage focus: payroll calculations, overtime rules, shift conflict detection, cross-org isolation, dashboard/status components, and env URL normalization
GitHub Actions workflow on push/PR to main:
- Backend:
npm run lint→npm run typecheck→npm test - Frontend:
npm run lint→npm run typecheck→npm run build