From 4b2ef9d843b99ae61e1aaa8e44f68f8435459c41 Mon Sep 17 00:00:00 2001 From: zouantchaw <44246692+zouantchaw@users.noreply.github.com> Date: Thu, 19 Mar 2026 10:28:13 +0100 Subject: [PATCH] fix(api): close M5 frontend contract gaps --- .../src/services/command-service.js | 83 +++++++++--- backend/query-api/src/routes/mobile.js | 11 ++ .../query-api/src/services/actor-context.js | 2 + .../src/services/mobile-query-service.js | 119 +++++++++++++++++- backend/query-api/test/mobile-routes.test.js | 12 ++ .../scripts/live-smoke-v2-unified.mjs | 35 ++++++ docs/BACKEND/API_GUIDES/V2/README.md | 23 ++++ .../API_GUIDES/V2/mobile-coding-agent-spec.md | 6 + docs/BACKEND/API_GUIDES/V2/unified-api.md | 23 ++++ 9 files changed, 293 insertions(+), 21 deletions(-) diff --git a/backend/command-api/src/services/command-service.js b/backend/command-api/src/services/command-service.js index ba257a12..d8fb721e 100644 --- a/backend/command-api/src/services/command-service.js +++ b/backend/command-api/src/services/command-service.js @@ -1287,18 +1287,43 @@ async function createAttendanceEvent(actor, payload, eventType) { }); } + async function loadExistingAttendanceSession() { + const existing = await client.query( + ` + SELECT id, status, check_in_at AS "clockInAt" + FROM attendance_sessions + WHERE assignment_id = $1 + ORDER BY updated_at DESC + LIMIT 1 + `, + [assignment.id] + ); + return existing.rows[0] || null; + } + const sessionResult = await client.query( ` - SELECT id, status + SELECT id, status, check_in_at AS "clockInAt" FROM attendance_sessions WHERE assignment_id = $1 `, [assignment.id] ); - if (eventType === 'CLOCK_IN' && sessionResult.rowCount > 0 && sessionResult.rows[0].status === 'OPEN') { - throw new AppError('ATTENDANCE_ALREADY_OPEN', 'Assignment already has an open attendance session', 409, { + if (eventType === 'CLOCK_IN' && sessionResult.rowCount > 0) { + const existingSession = sessionResult.rows[0]; + if (existingSession.status === 'OPEN') { + throw new AppError('ALREADY_CLOCKED_IN', 'An active attendance session already exists for this assignment', 409, { + assignmentId: assignment.id, + sessionId: existingSession.id, + clockInAt: existingSession.clockInAt || null, + }); + } + + throw new AppError('ATTENDANCE_SESSION_EXISTS', 'Attendance session already exists for this assignment', 409, { assignmentId: assignment.id, + sessionId: existingSession.id, + clockInAt: existingSession.clockInAt || null, }); } @@ -1414,21 +1439,43 @@ async function createAttendanceEvent(actor, payload, eventType) { let sessionId; if (eventType === 'CLOCK_IN') { - const insertedSession = await client.query( - ` - INSERT INTO attendance_sessions ( - tenant_id, - assignment_id, - staff_id, - clock_in_event_id, - status, - check_in_at - ) - VALUES ($1, $2, $3, $4, 'OPEN', $5) - RETURNING id - `, - [assignment.tenant_id, assignment.id, assignment.staff_id, eventResult.rows[0].id, capturedAt] - ); + let insertedSession; + try { + insertedSession = await client.query( + ` + INSERT INTO attendance_sessions ( + tenant_id, + assignment_id, + staff_id, + clock_in_event_id, + status, + check_in_at + ) + VALUES ($1, $2, $3, $4, 'OPEN', $5) + RETURNING id + `, + [assignment.tenant_id, assignment.id, assignment.staff_id, eventResult.rows[0].id, capturedAt] + ); + } catch (error) { + if (error?.code !== '23505') { + throw error; + } + + const existingSession = await loadExistingAttendanceSession(); + if (existingSession?.status === 'OPEN') { + throw new AppError('ALREADY_CLOCKED_IN', 'An active attendance session already exists for this assignment', 409, { + assignmentId: assignment.id, + sessionId: existingSession.id, + clockInAt: existingSession.clockInAt || null, + }); + } + + throw new AppError('ATTENDANCE_SESSION_EXISTS', 'Attendance session already exists for this assignment', 409, { + assignmentId: assignment.id, + sessionId: existingSession?.id || null, + clockInAt: existingSession?.clockInAt || null, + }); + } sessionId = insertedSession.rows[0].id; await client.query( ` diff --git a/backend/query-api/src/routes/mobile.js b/backend/query-api/src/routes/mobile.js index 8bdc257a..947c2bc1 100644 --- a/backend/query-api/src/routes/mobile.js +++ b/backend/query-api/src/routes/mobile.js @@ -21,6 +21,7 @@ import { getReportSummary, getSavings, getStaffDashboard, + getStaffReliabilityStats, getStaffProfileCompletion, getStaffSession, getStaffShiftDetail, @@ -89,6 +90,7 @@ const defaultQueryService = { getSpendBreakdown, getSpendReport, getStaffDashboard, + getStaffReliabilityStats, getStaffProfileCompletion, getStaffSession, getStaffShiftDetail, @@ -443,6 +445,15 @@ export function createMobileQueryRouter(queryService = defaultQueryService) { } }); + router.get('/staff/profile/stats', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => { + try { + const data = await queryService.getStaffReliabilityStats(req.actor.uid); + return res.status(200).json({ ...data, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + router.get('/staff/dashboard', requireAuth, requirePolicy('staff.dashboard.read', 'dashboard'), async (req, res, next) => { try { const data = await queryService.getStaffDashboard(req.actor.uid); diff --git a/backend/query-api/src/services/actor-context.js b/backend/query-api/src/services/actor-context.js index 30d23aa5..efb118c0 100644 --- a/backend/query-api/src/services/actor-context.js +++ b/backend/query-api/src/services/actor-context.js @@ -70,6 +70,8 @@ export async function loadActorContext(uid) { s.phone, s.primary_role AS "primaryRole", s.onboarding_status AS "onboardingStatus", + s.average_rating AS "averageRating", + s.rating_count AS "ratingCount", s.status, s.metadata, w.id AS "workforceId", diff --git a/backend/query-api/src/services/mobile-query-service.js b/backend/query-api/src/services/mobile-query-service.js index e6e54e9b..e8942dff 100644 --- a/backend/query-api/src/services/mobile-query-service.js +++ b/backend/query-api/src/services/mobile-query-service.js @@ -52,6 +52,37 @@ function metadataBoolean(metadata, key, fallback = false) { return fallback; } +function roundToOneDecimal(value) { + return Math.round(value * 10) / 10; +} + +function clamp(value, min, max) { + return Math.min(Math.max(value, min), max); +} + +function computeReliabilityScore({ + totalShifts, + noShowCount, + cancellationCount, + averageRating, + onTimeRate, +}) { + const participationBase = totalShifts + noShowCount + cancellationCount; + const completionRate = participationBase === 0 + ? 100 + : (totalShifts / participationBase) * 100; + const ratingScore = clamp((averageRating / 5) * 100, 0, 100); + return roundToOneDecimal( + clamp( + (onTimeRate * 0.45) + + (completionRate * 0.35) + + (ratingScore * 0.20), + 0, + 100 + ) + ); +} + function membershipDisplayName(row) { const firstName = row?.firstName || row?.metadata?.firstName || null; const lastName = row?.lastName || row?.metadata?.lastName || null; @@ -96,6 +127,68 @@ export async function getStaffSession(actorUid) { return context; } +export async function getStaffReliabilityStats(actorUid) { + const context = await requireStaffContext(actorUid); + const result = await query( + ` + SELECT + COUNT(*) FILTER (WHERE a.status IN ('CHECKED_OUT', 'COMPLETED'))::INTEGER AS "totalShifts", + COUNT(*) FILTER (WHERE attendance_sessions.check_in_at IS NOT NULL)::INTEGER AS "clockedInCount", + COUNT(*) FILTER ( + WHERE attendance_sessions.check_in_at IS NOT NULL + AND attendance_sessions.check_in_at <= s.starts_at + INTERVAL '5 minutes' + )::INTEGER AS "onTimeShiftCount", + COUNT(*) FILTER (WHERE a.status = 'NO_SHOW')::INTEGER AS "noShowCount", + COUNT(*) FILTER ( + WHERE a.status = 'CANCELLED' + AND ( + COALESCE(a.metadata->>'declinedBy', '') = $3 + OR COALESCE(a.metadata->>'cancelledBy', '') = $3 + ) + )::INTEGER AS "cancellationCount", + COALESCE(st.average_rating, 0)::NUMERIC(3, 2) AS "averageRating", + COALESCE(st.rating_count, 0)::INTEGER AS "ratingCount" + FROM staffs st + LEFT JOIN assignments a ON a.staff_id = st.id AND a.tenant_id = st.tenant_id + LEFT JOIN shifts s ON s.id = a.shift_id + LEFT JOIN attendance_sessions ON attendance_sessions.assignment_id = a.id + WHERE st.tenant_id = $1 + AND st.id = $2 + GROUP BY st.id + `, + [context.tenant.tenantId, context.staff.staffId, actorUid] + ); + + const metrics = result.rows[0] || {}; + const totalShifts = Number(metrics.totalShifts || 0); + const clockedInCount = Number(metrics.clockedInCount || 0); + const onTimeShiftCount = Number(metrics.onTimeShiftCount || 0); + const noShowCount = Number(metrics.noShowCount || 0); + const cancellationCount = Number(metrics.cancellationCount || 0); + const averageRating = Number(metrics.averageRating || 0); + const ratingCount = Number(metrics.ratingCount || 0); + const onTimeRate = clockedInCount === 0 + ? 0 + : roundToOneDecimal((onTimeShiftCount / clockedInCount) * 100); + + return { + staffId: context.staff.staffId, + totalShifts, + averageRating, + ratingCount, + onTimeRate, + noShowCount, + cancellationCount, + reliabilityScore: computeReliabilityScore({ + totalShifts, + noShowCount, + cancellationCount, + averageRating, + onTimeRate, + }), + }; +} + export async function getClientDashboard(actorUid) { const context = await requireClientContext(actorUid); const businessId = context.business.businessId; @@ -353,19 +446,33 @@ export async function listCoverageByDate(actorUid, { date }) { s.title, s.starts_at AS "startsAt", s.ends_at AS "endsAt", + COALESCE(cp.label, s.location_name) AS "locationName", + COALESCE(s.location_address, cp.address) AS "locationAddress", s.required_workers AS "requiredWorkers", s.assigned_workers AS "assignedWorkers", - sr.role_name AS "roleName", + COALESCE(sr_assigned.role_name, sr_primary.role_name) AS "roleName", a.id AS "assignmentId", a.status AS "assignmentStatus", st.id AS "staffId", st.full_name AS "staffName", - attendance_sessions.check_in_at AS "checkInAt" + attendance_sessions.check_in_at AS "checkInAt", + COALESCE(staff_reviews.id IS NOT NULL, FALSE) AS "hasReview" FROM shifts s - LEFT JOIN shift_roles sr ON sr.shift_id = s.id + LEFT JOIN clock_points cp ON cp.id = s.clock_point_id + LEFT JOIN LATERAL ( + SELECT role_name + FROM shift_roles + WHERE shift_id = s.id + ORDER BY created_at ASC, role_name ASC + LIMIT 1 + ) sr_primary ON TRUE LEFT JOIN assignments a ON a.shift_id = s.id + LEFT JOIN shift_roles sr_assigned ON sr_assigned.id = a.shift_role_id LEFT JOIN staffs st ON st.id = a.staff_id LEFT JOIN attendance_sessions ON attendance_sessions.assignment_id = a.id + LEFT JOIN staff_reviews ON staff_reviews.business_id = s.business_id + AND staff_reviews.assignment_id = a.id + AND staff_reviews.staff_id = a.staff_id WHERE s.tenant_id = $1 AND s.business_id = $2 AND s.starts_at >= $3::timestamptz @@ -380,6 +487,8 @@ export async function listCoverageByDate(actorUid, { date }) { const current = grouped.get(row.shiftId) || { shiftId: row.shiftId, roleName: row.roleName, + locationName: row.locationName, + locationAddress: row.locationAddress, timeRange: { startsAt: row.startsAt, endsAt: row.endsAt, @@ -388,6 +497,9 @@ export async function listCoverageByDate(actorUid, { date }) { assignedWorkerCount: row.assignedWorkers, assignedWorkers: [], }; + if (!current.roleName && row.roleName) current.roleName = row.roleName; + if (!current.locationName && row.locationName) current.locationName = row.locationName; + if (!current.locationAddress && row.locationAddress) current.locationAddress = row.locationAddress; if (row.staffId) { current.assignedWorkers.push({ assignmentId: row.assignmentId, @@ -395,6 +507,7 @@ export async function listCoverageByDate(actorUid, { date }) { fullName: row.staffName, status: row.assignmentStatus, checkInAt: row.checkInAt, + hasReview: Boolean(row.hasReview), }); } grouped.set(row.shiftId, current); diff --git a/backend/query-api/test/mobile-routes.test.js b/backend/query-api/test/mobile-routes.test.js index 8a7946dc..f810029d 100644 --- a/backend/query-api/test/mobile-routes.test.js +++ b/backend/query-api/test/mobile-routes.test.js @@ -27,6 +27,7 @@ function createMobileQueryService() { getSpendReport: async () => ({ totals: { amountCents: 2000 } }), getSpendBreakdown: async () => ([{ category: 'Barista', amountCents: 1000 }]), getStaffDashboard: async () => ({ staffName: 'Ana Barista' }), + getStaffReliabilityStats: async () => ({ totalShifts: 12, reliabilityScore: 96.4 }), getStaffProfileCompletion: async () => ({ completed: true }), getStaffSession: async () => ({ staff: { staffId: 's1' } }), getStaffShiftDetail: async () => ({ shiftId: 'shift-1' }), @@ -101,6 +102,17 @@ test('GET /query/staff/dashboard returns injected dashboard', async () => { assert.equal(res.body.staffName, 'Ana Barista'); }); +test('GET /query/staff/profile/stats returns injected reliability stats', async () => { + const app = createApp({ mobileQueryService: createMobileQueryService() }); + const res = await request(app) + .get('/query/staff/profile/stats') + .set('Authorization', 'Bearer test-token'); + + assert.equal(res.status, 200); + assert.equal(res.body.totalShifts, 12); + assert.equal(res.body.reliabilityScore, 96.4); +}); + test('GET /query/staff/shifts/:shiftId returns injected shift detail', async () => { const app = createApp({ mobileQueryService: createMobileQueryService() }); const res = await request(app) diff --git a/backend/unified-api/scripts/live-smoke-v2-unified.mjs b/backend/unified-api/scripts/live-smoke-v2-unified.mjs index f6936a38..d3080cb4 100644 --- a/backend/unified-api/scripts/live-smoke-v2-unified.mjs +++ b/backend/unified-api/scripts/live-smoke-v2-unified.mjs @@ -343,6 +343,12 @@ async function main() { token: ownerSession.sessionToken, }); assert.ok(Array.isArray(coverage.items)); + const seededCoverageShift = coverage.items.find((item) => item.shiftId === fixture.shifts.assigned.id) || coverage.items[0]; + assert.ok(seededCoverageShift); + assert.ok(seededCoverageShift.locationName); + if (seededCoverageShift.assignedWorkers?.[0]) { + assert.equal(typeof seededCoverageShift.assignedWorkers[0].hasReview, 'boolean'); + } logStep('client.coverage.ok', { count: coverage.items.length }); const coverageStats = await apiCall(`/client/coverage/stats?date=${isoDate(0)}`, { @@ -753,6 +759,13 @@ async function main() { }); logStep('staff.profile-completion.ok', staffProfileCompletion); + const staffProfileStats = await apiCall('/staff/profile/stats', { + token: staffAuth.idToken, + }); + assert.equal(typeof staffProfileStats.totalShifts, 'number'); + assert.equal(typeof staffProfileStats.reliabilityScore, 'number'); + logStep('staff.profile.stats.ok', staffProfileStats); + const staffAvailability = await apiCall('/staff/availability', { token: staffAuth.idToken, }); @@ -1129,6 +1142,28 @@ async function main() { assert.ok(clockIn.securityProofId); logStep('staff.clock-in.ok', clockIn); + const duplicateClockIn = await apiCall('/staff/clock-in', { + method: 'POST', + token: staffAuth.idToken, + idempotencyKey: uniqueKey('staff-clock-in-duplicate'), + body: { + shiftId: fixture.shifts.assigned.id, + sourceType: 'GEO', + deviceId: 'smoke-iphone-15-pro', + latitude: fixture.clockPoint.latitude, + longitude: fixture.clockPoint.longitude, + accuracyMeters: 8, + proofNonce: uniqueKey('geo-proof-clock-in-duplicate'), + proofTimestamp: isoTimestamp(0), + capturedAt: isoTimestamp(0), + }, + expectedStatus: 409, + allowFailure: true, + }); + assert.equal(duplicateClockIn.statusCode, 409); + assert.equal(duplicateClockIn.body.code, 'ALREADY_CLOCKED_IN'); + logStep('staff.clock-in.duplicate.ok', duplicateClockIn.body); + const attendanceStatusAfterClockIn = await apiCall('/staff/clock-in/status', { token: staffAuth.idToken, }); diff --git a/docs/BACKEND/API_GUIDES/V2/README.md b/docs/BACKEND/API_GUIDES/V2/README.md index acc94c0c..1b812994 100644 --- a/docs/BACKEND/API_GUIDES/V2/README.md +++ b/docs/BACKEND/API_GUIDES/V2/README.md @@ -81,6 +81,29 @@ All routes return the same error envelope: } ``` +## 3.1) Time handling + +V2 stores operational timestamps in UTC using PostgreSQL `TIMESTAMPTZ`. + +Rules: + +- frontend sends UTC timestamps to backend +- backend returns ISO 8601 UTC timestamps for source-of-truth fields +- frontend converts those timestamps to local time for display + +Source-of-truth timestamp fields include: + +- `startsAt` +- `endsAt` +- `startTime` +- `endTime` +- `clockInAt` +- `clockOutAt` +- `createdAt` +- `updatedAt` + +Helper fields like `date` are UTC-derived helpers and should not replace the raw timestamp fields. + ## 4) Attendance policy and monitoring V2 now supports an explicit attendance proof policy: diff --git a/docs/BACKEND/API_GUIDES/V2/mobile-coding-agent-spec.md b/docs/BACKEND/API_GUIDES/V2/mobile-coding-agent-spec.md index a369f422..cb3f91d5 100644 --- a/docs/BACKEND/API_GUIDES/V2/mobile-coding-agent-spec.md +++ b/docs/BACKEND/API_GUIDES/V2/mobile-coding-agent-spec.md @@ -23,6 +23,7 @@ Supporting docs: - Send `Idempotency-Key` on every write route. - Treat `order`, `shift`, `shiftRole`, and `assignment` as different objects. - For staff shift applications, `roleId` must come from the response of `GET /staff/shifts/open`. +- Treat API timestamp fields as UTC and convert them to local time in the app. ## 2) What is implemented now @@ -145,6 +146,8 @@ Rules: - worker rating happens through `POST /client/coverage/reviews` - the same endpoint also supports `markAsFavorite` to add or remove a worker from business favorites - blocking a worker is done through the same endpoint using `markAsBlocked` +- coverage shift items now include `locationName` and `locationAddress` +- assigned worker items now include `hasReview` - dispatch ranking order is: 1. `CORE` 2. `CERTIFIED_LOCATION` @@ -216,6 +219,7 @@ Important: - `GET /staff/session` - `GET /staff/dashboard` +- `GET /staff/profile/stats` - `GET /staff/profile-completion` ### Availability @@ -250,6 +254,7 @@ Staff shift detail and list rules: - assigned shifts include `clientName`, `hourlyRate`, `totalRate`, `startTime`, `endTime` - shift detail includes `clientName`, `latitude`, `longitude`, `hourlyRate`, `totalRate` - completed shifts include `date`, `clientName`, `startTime`, `endTime`, `hourlyRate`, `totalRate` +- `GET /staff/profile/stats` returns `totalShifts`, `averageRating`, `ratingCount`, `onTimeRate`, `noShowCount`, `cancellationCount`, `reliabilityScore` ### Clock in / clock out @@ -266,6 +271,7 @@ Clock-in payload rules: - send `overrideReason` only when geo override is allowed - send `proofNonce` and `proofTimestamp` on attendance writes - send `attestationProvider` and `attestationToken` only if the device has them +- if backend returns `ALREADY_CLOCKED_IN`, treat it as a valid retry-state signal and refresh attendance/session state Clock-in read rules: diff --git a/docs/BACKEND/API_GUIDES/V2/unified-api.md b/docs/BACKEND/API_GUIDES/V2/unified-api.md index 9e160f04..06153c0f 100644 --- a/docs/BACKEND/API_GUIDES/V2/unified-api.md +++ b/docs/BACKEND/API_GUIDES/V2/unified-api.md @@ -104,6 +104,11 @@ Coverage-review request payload may also send: If `markAsFavorite` is `true`, backend adds that worker to the business favorites list. If `markAsFavorite` is `false`, backend removes them from that list. If `markAsBlocked` is `true`, backend adds that staff member to the business-level blocked list and future apply or assign attempts are rejected until a later review sends `markAsBlocked: false`. +`GET /client/coverage` response notes: + +- each shift item includes `locationName` and `locationAddress` +- each assigned worker item includes `hasReview` + Swap-review routes: - `GET /client/coverage/swap-requests?status=OPEN` @@ -163,6 +168,7 @@ The manager is created as an invited business membership. If `hubId` is present, - `GET /staff/session` - `GET /staff/dashboard` +- `GET /staff/profile/stats` - `GET /staff/profile-completion` - `GET /staff/availability` - `GET /staff/clock-in/shifts/today` @@ -218,6 +224,21 @@ Example `GET /staff/clock-in/shifts/today` item: } ``` +Example `GET /staff/profile/stats` response: + +```json +{ + "staffId": "uuid", + "totalShifts": 12, + "averageRating": 4.8, + "ratingCount": 7, + "onTimeRate": 91.7, + "noShowCount": 1, + "cancellationCount": 0, + "reliabilityScore": 92.3 +} +``` + ### Staff writes - `POST /staff/profile/setup` @@ -296,12 +317,14 @@ These are exposed as direct unified aliases even though they are backed by `core - `NFC_REQUIRED` - `GEO_REQUIRED` - `EITHER` +- all source-of-truth timestamps are UTC ISO 8601 values. Frontend should convert them to local time for display. - For `POST /staff/clock-in` and `POST /staff/clock-out`: - send `nfcTagId` when clocking with NFC - send `latitude`, `longitude`, and `accuracyMeters` when clocking with geolocation - send `proofNonce` and `proofTimestamp` for attendance-proof logging; these are most important on NFC paths - send `attestationProvider` and `attestationToken` only when the device has a real attestation result to forward - send `overrideReason` only when the worker is bypassing a geofence failure and the shift/hub allows overrides + - if the worker is already clocked in, backend returns `409` with code `ALREADY_CLOCKED_IN` - `POST /staff/location-streams` is for the background tracking loop after a worker is already clocked in. - `GET /client/coverage/incidents` is the review feed for geofence breaches, missing-location batches, and clock-in overrides. - `GET /client/coverage/blocked-staff` is the review feed for workers currently blocked by that business.