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

@@ -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);

View File

@@ -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",

View File

@@ -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);

View File

@@ -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)