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