fix(api): close M5 frontend contract gaps

This commit is contained in:
zouantchaw
2026-03-19 10:28:13 +01:00
parent 3399dfdac7
commit 4b2ef9d843
9 changed files with 293 additions and 21 deletions

View File

@@ -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( const sessionResult = await client.query(
` `
SELECT id, status SELECT id, status, check_in_at AS "clockInAt"
FROM attendance_sessions FROM attendance_sessions
WHERE assignment_id = $1 WHERE assignment_id = $1
`, `,
[assignment.id] [assignment.id]
); );
if (eventType === 'CLOCK_IN' && sessionResult.rowCount > 0 && sessionResult.rows[0].status === 'OPEN') { if (eventType === 'CLOCK_IN' && sessionResult.rowCount > 0) {
throw new AppError('ATTENDANCE_ALREADY_OPEN', 'Assignment already has an open attendance session', 409, { 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, assignmentId: assignment.id,
sessionId: existingSession.id,
clockInAt: existingSession.clockInAt || null,
}); });
} }
@@ -1414,21 +1439,43 @@ async function createAttendanceEvent(actor, payload, eventType) {
let sessionId; let sessionId;
if (eventType === 'CLOCK_IN') { if (eventType === 'CLOCK_IN') {
const insertedSession = await client.query( let insertedSession;
` try {
INSERT INTO attendance_sessions ( insertedSession = await client.query(
tenant_id, `
assignment_id, INSERT INTO attendance_sessions (
staff_id, tenant_id,
clock_in_event_id, assignment_id,
status, staff_id,
check_in_at clock_in_event_id,
) status,
VALUES ($1, $2, $3, $4, 'OPEN', $5) check_in_at
RETURNING id )
`, VALUES ($1, $2, $3, $4, 'OPEN', $5)
[assignment.tenant_id, assignment.id, assignment.staff_id, eventResult.rows[0].id, capturedAt] 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; sessionId = insertedSession.rows[0].id;
await client.query( await client.query(
` `

View File

@@ -21,6 +21,7 @@ import {
getReportSummary, getReportSummary,
getSavings, getSavings,
getStaffDashboard, getStaffDashboard,
getStaffReliabilityStats,
getStaffProfileCompletion, getStaffProfileCompletion,
getStaffSession, getStaffSession,
getStaffShiftDetail, getStaffShiftDetail,
@@ -89,6 +90,7 @@ const defaultQueryService = {
getSpendBreakdown, getSpendBreakdown,
getSpendReport, getSpendReport,
getStaffDashboard, getStaffDashboard,
getStaffReliabilityStats,
getStaffProfileCompletion, getStaffProfileCompletion,
getStaffSession, getStaffSession,
getStaffShiftDetail, 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) => { router.get('/staff/dashboard', requireAuth, requirePolicy('staff.dashboard.read', 'dashboard'), async (req, res, next) => {
try { try {
const data = await queryService.getStaffDashboard(req.actor.uid); const data = await queryService.getStaffDashboard(req.actor.uid);

View File

@@ -70,6 +70,8 @@ export async function loadActorContext(uid) {
s.phone, s.phone,
s.primary_role AS "primaryRole", s.primary_role AS "primaryRole",
s.onboarding_status AS "onboardingStatus", s.onboarding_status AS "onboardingStatus",
s.average_rating AS "averageRating",
s.rating_count AS "ratingCount",
s.status, s.status,
s.metadata, s.metadata,
w.id AS "workforceId", w.id AS "workforceId",

View File

@@ -52,6 +52,37 @@ function metadataBoolean(metadata, key, fallback = false) {
return fallback; 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) { function membershipDisplayName(row) {
const firstName = row?.firstName || row?.metadata?.firstName || null; const firstName = row?.firstName || row?.metadata?.firstName || null;
const lastName = row?.lastName || row?.metadata?.lastName || null; const lastName = row?.lastName || row?.metadata?.lastName || null;
@@ -96,6 +127,68 @@ export async function getStaffSession(actorUid) {
return context; 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) { export async function getClientDashboard(actorUid) {
const context = await requireClientContext(actorUid); const context = await requireClientContext(actorUid);
const businessId = context.business.businessId; const businessId = context.business.businessId;
@@ -353,19 +446,33 @@ export async function listCoverageByDate(actorUid, { date }) {
s.title, s.title,
s.starts_at AS "startsAt", s.starts_at AS "startsAt",
s.ends_at AS "endsAt", 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.required_workers AS "requiredWorkers",
s.assigned_workers AS "assignedWorkers", 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.id AS "assignmentId",
a.status AS "assignmentStatus", a.status AS "assignmentStatus",
st.id AS "staffId", st.id AS "staffId",
st.full_name AS "staffName", 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 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 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 staffs st ON st.id = a.staff_id
LEFT JOIN attendance_sessions ON attendance_sessions.assignment_id = a.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 WHERE s.tenant_id = $1
AND s.business_id = $2 AND s.business_id = $2
AND s.starts_at >= $3::timestamptz AND s.starts_at >= $3::timestamptz
@@ -380,6 +487,8 @@ export async function listCoverageByDate(actorUid, { date }) {
const current = grouped.get(row.shiftId) || { const current = grouped.get(row.shiftId) || {
shiftId: row.shiftId, shiftId: row.shiftId,
roleName: row.roleName, roleName: row.roleName,
locationName: row.locationName,
locationAddress: row.locationAddress,
timeRange: { timeRange: {
startsAt: row.startsAt, startsAt: row.startsAt,
endsAt: row.endsAt, endsAt: row.endsAt,
@@ -388,6 +497,9 @@ export async function listCoverageByDate(actorUid, { date }) {
assignedWorkerCount: row.assignedWorkers, assignedWorkerCount: row.assignedWorkers,
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) { if (row.staffId) {
current.assignedWorkers.push({ current.assignedWorkers.push({
assignmentId: row.assignmentId, assignmentId: row.assignmentId,
@@ -395,6 +507,7 @@ export async function listCoverageByDate(actorUid, { date }) {
fullName: row.staffName, fullName: row.staffName,
status: row.assignmentStatus, status: row.assignmentStatus,
checkInAt: row.checkInAt, checkInAt: row.checkInAt,
hasReview: Boolean(row.hasReview),
}); });
} }
grouped.set(row.shiftId, current); grouped.set(row.shiftId, current);

View File

@@ -27,6 +27,7 @@ function createMobileQueryService() {
getSpendReport: async () => ({ totals: { amountCents: 2000 } }), getSpendReport: async () => ({ totals: { amountCents: 2000 } }),
getSpendBreakdown: async () => ([{ category: 'Barista', amountCents: 1000 }]), getSpendBreakdown: async () => ([{ category: 'Barista', amountCents: 1000 }]),
getStaffDashboard: async () => ({ staffName: 'Ana Barista' }), getStaffDashboard: async () => ({ staffName: 'Ana Barista' }),
getStaffReliabilityStats: async () => ({ totalShifts: 12, reliabilityScore: 96.4 }),
getStaffProfileCompletion: async () => ({ completed: true }), getStaffProfileCompletion: async () => ({ completed: true }),
getStaffSession: async () => ({ staff: { staffId: 's1' } }), getStaffSession: async () => ({ staff: { staffId: 's1' } }),
getStaffShiftDetail: async () => ({ shiftId: 'shift-1' }), 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'); 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 () => { test('GET /query/staff/shifts/:shiftId returns injected shift detail', async () => {
const app = createApp({ mobileQueryService: createMobileQueryService() }); const app = createApp({ mobileQueryService: createMobileQueryService() });
const res = await request(app) const res = await request(app)

View File

@@ -343,6 +343,12 @@ async function main() {
token: ownerSession.sessionToken, token: ownerSession.sessionToken,
}); });
assert.ok(Array.isArray(coverage.items)); 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 }); logStep('client.coverage.ok', { count: coverage.items.length });
const coverageStats = await apiCall(`/client/coverage/stats?date=${isoDate(0)}`, { const coverageStats = await apiCall(`/client/coverage/stats?date=${isoDate(0)}`, {
@@ -753,6 +759,13 @@ async function main() {
}); });
logStep('staff.profile-completion.ok', staffProfileCompletion); 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', { const staffAvailability = await apiCall('/staff/availability', {
token: staffAuth.idToken, token: staffAuth.idToken,
}); });
@@ -1129,6 +1142,28 @@ async function main() {
assert.ok(clockIn.securityProofId); assert.ok(clockIn.securityProofId);
logStep('staff.clock-in.ok', clockIn); 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', { const attendanceStatusAfterClockIn = await apiCall('/staff/clock-in/status', {
token: staffAuth.idToken, token: staffAuth.idToken,
}); });

View File

@@ -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 ## 4) Attendance policy and monitoring
V2 now supports an explicit attendance proof policy: V2 now supports an explicit attendance proof policy:

View File

@@ -23,6 +23,7 @@ Supporting docs:
- Send `Idempotency-Key` on every write route. - Send `Idempotency-Key` on every write route.
- Treat `order`, `shift`, `shiftRole`, and `assignment` as different objects. - Treat `order`, `shift`, `shiftRole`, and `assignment` as different objects.
- For staff shift applications, `roleId` must come from the response of `GET /staff/shifts/open`. - 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 ## 2) What is implemented now
@@ -145,6 +146,8 @@ Rules:
- worker rating happens through `POST /client/coverage/reviews` - worker rating happens through `POST /client/coverage/reviews`
- the same endpoint also supports `markAsFavorite` to add or remove a worker from business favorites - 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` - 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: - dispatch ranking order is:
1. `CORE` 1. `CORE`
2. `CERTIFIED_LOCATION` 2. `CERTIFIED_LOCATION`
@@ -216,6 +219,7 @@ Important:
- `GET /staff/session` - `GET /staff/session`
- `GET /staff/dashboard` - `GET /staff/dashboard`
- `GET /staff/profile/stats`
- `GET /staff/profile-completion` - `GET /staff/profile-completion`
### Availability ### Availability
@@ -250,6 +254,7 @@ Staff shift detail and list rules:
- assigned shifts include `clientName`, `hourlyRate`, `totalRate`, `startTime`, `endTime` - assigned shifts include `clientName`, `hourlyRate`, `totalRate`, `startTime`, `endTime`
- shift detail includes `clientName`, `latitude`, `longitude`, `hourlyRate`, `totalRate` - shift detail includes `clientName`, `latitude`, `longitude`, `hourlyRate`, `totalRate`
- completed shifts include `date`, `clientName`, `startTime`, `endTime`, `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 ### Clock in / clock out
@@ -266,6 +271,7 @@ Clock-in payload rules:
- send `overrideReason` only when geo override is allowed - send `overrideReason` only when geo override is allowed
- send `proofNonce` and `proofTimestamp` on attendance writes - send `proofNonce` and `proofTimestamp` on attendance writes
- send `attestationProvider` and `attestationToken` only if the device has them - 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: Clock-in read rules:

View File

@@ -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`. 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: Swap-review routes:
- `GET /client/coverage/swap-requests?status=OPEN` - `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/session`
- `GET /staff/dashboard` - `GET /staff/dashboard`
- `GET /staff/profile/stats`
- `GET /staff/profile-completion` - `GET /staff/profile-completion`
- `GET /staff/availability` - `GET /staff/availability`
- `GET /staff/clock-in/shifts/today` - `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 ### Staff writes
- `POST /staff/profile/setup` - `POST /staff/profile/setup`
@@ -296,12 +317,14 @@ These are exposed as direct unified aliases even though they are backed by `core
- `NFC_REQUIRED` - `NFC_REQUIRED`
- `GEO_REQUIRED` - `GEO_REQUIRED`
- `EITHER` - `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`: - For `POST /staff/clock-in` and `POST /staff/clock-out`:
- send `nfcTagId` when clocking with NFC - send `nfcTagId` when clocking with NFC
- send `latitude`, `longitude`, and `accuracyMeters` when clocking with geolocation - 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 `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 `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 - 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. - `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/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. - `GET /client/coverage/blocked-staff` is the review feed for workers currently blocked by that business.