fix(api): close M5 frontend contract gaps
This commit is contained in:
@@ -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(
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user