feat(api): complete M5 swap and dispatch backend slice

This commit is contained in:
zouantchaw
2026-03-18 10:40:04 +01:00
parent 32f6cd55c8
commit 26a853184f
18 changed files with 2170 additions and 109 deletions

View File

@@ -34,6 +34,8 @@ import {
listCostCenters,
listCoreTeam,
listCoverageByDate,
listCoverageDispatchCandidates,
listCoverageDispatchTeams,
listCompletedShifts,
listEmergencyContacts,
listFaqCategories,
@@ -44,6 +46,7 @@ import {
listOpenShifts,
listTaxForms,
listTimeCardEntries,
listSwapRequests,
listOrderItemsByDateRange,
listPaymentsHistory,
listPendingAssignments,
@@ -99,6 +102,8 @@ const defaultQueryService = {
listCostCenters,
listCoreTeam,
listCoverageByDate,
listCoverageDispatchCandidates,
listCoverageDispatchTeams,
listCompletedShifts,
listEmergencyContacts,
listFaqCategories,
@@ -109,6 +114,7 @@ const defaultQueryService = {
listOpenShifts,
listTaxForms,
listTimeCardEntries,
listSwapRequests,
listOrderItemsByDateRange,
listPaymentsHistory,
listPendingAssignments,
@@ -266,6 +272,33 @@ export function createMobileQueryRouter(queryService = defaultQueryService) {
}
});
router.get('/client/coverage/swap-requests', requireAuth, requirePolicy('coverage.read', 'coverage'), async (req, res, next) => {
try {
const items = await queryService.listSwapRequests(req.actor.uid, req.query);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/coverage/dispatch-teams', requireAuth, requirePolicy('coverage.read', 'coverage'), async (req, res, next) => {
try {
const items = await queryService.listCoverageDispatchTeams(req.actor.uid, req.query);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/coverage/dispatch-candidates', requireAuth, requirePolicy('coverage.read', 'coverage'), async (req, res, next) => {
try {
const items = await queryService.listCoverageDispatchCandidates(req.actor.uid, req.query);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/hubs', requireAuth, requirePolicy('hubs.read', 'hub'), async (req, res, next) => {
try {
const items = await queryService.listHubs(req.actor.uid);

View File

@@ -906,6 +906,7 @@ export async function listOpenShifts(actorUid, { limit, search } = {}) {
SELECT
s.id AS "shiftId",
sr.id AS "roleId",
NULL::uuid AS "swapRequestId",
b.business_name AS "clientName",
sr.role_name AS "roleName",
COALESCE(cp.label, s.location_name) AS location,
@@ -932,12 +933,40 @@ export async function listOpenShifts(actorUid, { limit, search } = {}) {
) AS "totalRate",
COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType",
FALSE AS "instantBook",
sr.workers_needed AS "requiredWorkerCount"
sr.workers_needed AS "requiredWorkerCount",
COALESCE(dispatch.team_type, 'MARKETPLACE') AS "dispatchTeam",
COALESCE(dispatch.priority, 3) AS "dispatchPriority"
FROM shifts s
JOIN shift_roles sr ON sr.shift_id = s.id
JOIN orders o ON o.id = s.order_id
JOIN businesses b ON b.id = s.business_id
LEFT JOIN clock_points cp ON cp.id = s.clock_point_id
LEFT JOIN LATERAL (
SELECT
dtm.team_type,
CASE dtm.team_type
WHEN 'CORE' THEN 1
WHEN 'CERTIFIED_LOCATION' THEN 2
ELSE 3
END AS priority
FROM dispatch_team_memberships dtm
WHERE dtm.tenant_id = $1
AND dtm.business_id = s.business_id
AND dtm.staff_id = $3
AND dtm.status = 'ACTIVE'
AND dtm.effective_at <= NOW()
AND (dtm.expires_at IS NULL OR dtm.expires_at > NOW())
AND (dtm.hub_id IS NULL OR dtm.hub_id = s.clock_point_id)
ORDER BY
CASE dtm.team_type
WHEN 'CORE' THEN 1
WHEN 'CERTIFIED_LOCATION' THEN 2
ELSE 3
END ASC,
CASE WHEN dtm.hub_id = s.clock_point_id THEN 0 ELSE 1 END ASC,
dtm.created_at ASC
LIMIT 1
) dispatch ON TRUE
WHERE s.tenant_id = $1
AND s.status = 'OPEN'
AND sr.role_code = $4
@@ -954,6 +983,7 @@ export async function listOpenShifts(actorUid, { limit, search } = {}) {
SELECT
s.id AS "shiftId",
sr.id AS "roleId",
ssr.id AS "swapRequestId",
b.business_name AS "clientName",
sr.role_name AS "roleName",
COALESCE(cp.label, s.location_name) AS location,
@@ -980,14 +1010,45 @@ export async function listOpenShifts(actorUid, { limit, search } = {}) {
) AS "totalRate",
COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType",
FALSE AS "instantBook",
1::INTEGER AS "requiredWorkerCount"
FROM assignments a
1::INTEGER AS "requiredWorkerCount",
COALESCE(dispatch.team_type, 'MARKETPLACE') AS "dispatchTeam",
COALESCE(dispatch.priority, 3) AS "dispatchPriority"
FROM shift_swap_requests ssr
JOIN assignments a ON a.id = ssr.original_assignment_id
JOIN shifts s ON s.id = a.shift_id
JOIN shift_roles sr ON sr.id = a.shift_role_id
JOIN orders o ON o.id = s.order_id
JOIN businesses b ON b.id = s.business_id
LEFT JOIN clock_points cp ON cp.id = s.clock_point_id
LEFT JOIN LATERAL (
SELECT
dtm.team_type,
CASE dtm.team_type
WHEN 'CORE' THEN 1
WHEN 'CERTIFIED_LOCATION' THEN 2
ELSE 3
END AS priority
FROM dispatch_team_memberships dtm
WHERE dtm.tenant_id = $1
AND dtm.business_id = s.business_id
AND dtm.staff_id = $3
AND dtm.status = 'ACTIVE'
AND dtm.effective_at <= NOW()
AND (dtm.expires_at IS NULL OR dtm.expires_at > NOW())
AND (dtm.hub_id IS NULL OR dtm.hub_id = s.clock_point_id)
ORDER BY
CASE dtm.team_type
WHEN 'CORE' THEN 1
WHEN 'CERTIFIED_LOCATION' THEN 2
ELSE 3
END ASC,
CASE WHEN dtm.hub_id = s.clock_point_id THEN 0 ELSE 1 END ASC,
dtm.created_at ASC
LIMIT 1
) dispatch ON TRUE
WHERE a.tenant_id = $1
AND ssr.status = 'OPEN'
AND ssr.expires_at > NOW()
AND a.status = 'SWAP_REQUESTED'
AND a.staff_id <> $3
AND sr.role_code = $4
@@ -1006,7 +1067,7 @@ export async function listOpenShifts(actorUid, { limit, search } = {}) {
UNION ALL
SELECT * FROM swap_roles
) items
ORDER BY "startTime" ASC
ORDER BY "dispatchPriority" ASC, "startTime" ASC
LIMIT $5
`,
[
@@ -1369,17 +1430,165 @@ export async function listStaffBenefitHistory(actorUid, { limit, offset } = {})
return result.rows;
}
export async function listCoreTeam(actorUid) {
export async function listSwapRequests(actorUid, { shiftId, status = 'OPEN', limit } = {}) {
const context = await requireClientContext(actorUid);
const result = await query(
const safeLimit = parseLimit(limit, 20, 100);
const allowedStatuses = new Set(['OPEN', 'RESOLVED', 'CANCELLED', 'EXPIRED', 'AUTO_CANCELLED']);
const normalizedStatus = allowedStatuses.has(`${status || 'OPEN'}`.toUpperCase())
? `${status || 'OPEN'}`.toUpperCase()
: 'OPEN';
const swapResult = await query(
`
SELECT
srq.id AS "swapRequestId",
srq.shift_id AS "shiftId",
srq.shift_role_id AS "roleId",
srq.original_assignment_id AS "originalAssignmentId",
srq.original_staff_id AS "originalStaffId",
srq.status,
srq.reason,
srq.expires_at AS "expiresAt",
srq.resolved_at AS "resolvedAt",
s.title AS "shiftTitle",
s.starts_at AS "startTime",
s.ends_at AS "endTime",
COALESCE(cp.label, s.location_name) AS location,
COALESCE(cp.address, s.location_address) AS address,
b.business_name AS "clientName",
st.full_name AS "originalStaffName",
sr.role_name AS "roleName"
FROM shift_swap_requests srq
JOIN shifts s ON s.id = srq.shift_id
JOIN shift_roles sr ON sr.id = srq.shift_role_id
JOIN staffs st ON st.id = srq.original_staff_id
JOIN businesses b ON b.id = srq.business_id
LEFT JOIN clock_points cp ON cp.id = s.clock_point_id
WHERE srq.tenant_id = $1
AND srq.business_id = $2
AND ($3::uuid IS NULL OR srq.shift_id = $3)
AND srq.status = $4
ORDER BY srq.created_at DESC
LIMIT $5
`,
[context.tenant.tenantId, context.business.businessId, shiftId || null, normalizedStatus, safeLimit]
);
if (swapResult.rowCount === 0) {
return [];
}
const swapIds = swapResult.rows.map((row) => row.swapRequestId);
const candidateResult = await query(
`
SELECT
srq.id AS "swapRequestId",
app.id AS "applicationId",
app.status AS "applicationStatus",
app.created_at AS "appliedAt",
st.id AS "staffId",
st.full_name AS "fullName",
st.primary_role AS "primaryRole",
st.average_rating AS "averageRating",
st.rating_count AS "ratingCount",
TRUE AS favorite
COALESCE(dispatch.team_type, 'MARKETPLACE') AS "dispatchTeam",
COALESCE(dispatch.priority, 3) AS "dispatchPriority"
FROM shift_swap_requests srq
JOIN shifts s ON s.id = srq.shift_id
JOIN applications app ON app.shift_role_id = srq.shift_role_id
JOIN staffs st ON st.id = app.staff_id
LEFT JOIN LATERAL (
SELECT
dtm.team_type,
CASE dtm.team_type
WHEN 'CORE' THEN 1
WHEN 'CERTIFIED_LOCATION' THEN 2
ELSE 3
END AS priority
FROM dispatch_team_memberships dtm
WHERE dtm.tenant_id = srq.tenant_id
AND dtm.business_id = srq.business_id
AND dtm.staff_id = st.id
AND dtm.status = 'ACTIVE'
AND dtm.effective_at <= NOW()
AND (dtm.expires_at IS NULL OR dtm.expires_at > NOW())
AND (dtm.hub_id IS NULL OR dtm.hub_id = s.clock_point_id)
ORDER BY
CASE dtm.team_type
WHEN 'CORE' THEN 1
WHEN 'CERTIFIED_LOCATION' THEN 2
ELSE 3
END ASC,
CASE WHEN dtm.hub_id = s.clock_point_id THEN 0 ELSE 1 END ASC,
dtm.created_at ASC
LIMIT 1
) dispatch ON TRUE
WHERE srq.id = ANY($1::uuid[])
AND app.status IN ('PENDING', 'CONFIRMED')
ORDER BY srq.created_at DESC, "dispatchPriority" ASC, st.average_rating DESC, app.created_at ASC
`,
[swapIds]
);
const candidatesBySwapId = new Map();
for (const row of candidateResult.rows) {
if (!candidatesBySwapId.has(row.swapRequestId)) {
candidatesBySwapId.set(row.swapRequestId, []);
}
candidatesBySwapId.get(row.swapRequestId).push(row);
}
return swapResult.rows.map((row) => ({
...row,
candidates: candidatesBySwapId.get(row.swapRequestId) || [],
candidateCount: (candidatesBySwapId.get(row.swapRequestId) || []).length,
}));
}
export async function listCoreTeam(actorUid) {
const context = await requireClientContext(actorUid);
const result = await query(
`
SELECT
dtm.id AS "membershipId",
st.id AS "staffId",
st.full_name AS "fullName",
st.primary_role AS "primaryRole",
st.average_rating AS "averageRating",
st.rating_count AS "ratingCount",
COALESCE(sf.id IS NOT NULL, FALSE) AS favorite,
dtm.team_type AS "teamType"
FROM dispatch_team_memberships dtm
JOIN staffs st ON st.id = dtm.staff_id
LEFT JOIN staff_favorites sf
ON sf.staff_id = dtm.staff_id
AND sf.tenant_id = dtm.tenant_id
AND sf.business_id = dtm.business_id
WHERE dtm.tenant_id = $1
AND dtm.business_id = $2
AND dtm.status = 'ACTIVE'
AND dtm.team_type = 'CORE'
AND dtm.effective_at <= NOW()
AND (dtm.expires_at IS NULL OR dtm.expires_at > NOW())
ORDER BY st.average_rating DESC, st.full_name ASC
`,
[context.tenant.tenantId, context.business.businessId]
);
if (result.rowCount > 0) {
return result.rows;
}
const favoritesFallback = await query(
`
SELECT
NULL::uuid AS "membershipId",
st.id AS "staffId",
st.full_name AS "fullName",
st.primary_role AS "primaryRole",
st.average_rating AS "averageRating",
st.rating_count AS "ratingCount",
TRUE AS favorite,
'CORE'::text AS "teamType"
FROM staff_favorites sf
JOIN staffs st ON st.id = sf.staff_id
WHERE sf.tenant_id = $1
@@ -1388,6 +1597,143 @@ export async function listCoreTeam(actorUid) {
`,
[context.tenant.tenantId, context.business.businessId]
);
return favoritesFallback.rows;
}
export async function listCoverageDispatchTeams(actorUid, { hubId, teamType } = {}) {
const context = await requireClientContext(actorUid);
const normalizedTeamType = teamType ? `${teamType}`.toUpperCase() : null;
const result = await query(
`
SELECT
dtm.id AS "membershipId",
dtm.staff_id AS "staffId",
st.full_name AS "fullName",
st.primary_role AS "primaryRole",
dtm.team_type AS "teamType",
CASE dtm.team_type
WHEN 'CORE' THEN 1
WHEN 'CERTIFIED_LOCATION' THEN 2
ELSE 3
END AS "dispatchPriority",
dtm.source,
dtm.status,
dtm.reason,
dtm.effective_at AS "effectiveAt",
dtm.expires_at AS "expiresAt",
dtm.hub_id AS "hubId",
cp.label AS "hubLabel"
FROM dispatch_team_memberships dtm
JOIN staffs st ON st.id = dtm.staff_id
LEFT JOIN clock_points cp ON cp.id = dtm.hub_id
WHERE dtm.tenant_id = $1
AND dtm.business_id = $2
AND dtm.status = 'ACTIVE'
AND dtm.effective_at <= NOW()
AND (dtm.expires_at IS NULL OR dtm.expires_at > NOW())
AND ($3::uuid IS NULL OR dtm.hub_id = $3)
AND ($4::text IS NULL OR dtm.team_type = $4)
ORDER BY "dispatchPriority" ASC, st.full_name ASC
`,
[context.tenant.tenantId, context.business.businessId, hubId || null, normalizedTeamType]
);
return result.rows;
}
export async function listCoverageDispatchCandidates(actorUid, { shiftId, roleId, limit } = {}) {
const context = await requireClientContext(actorUid);
if (!shiftId) {
throw new AppError('VALIDATION_ERROR', 'shiftId is required', 400, { field: 'shiftId' });
}
const safeLimit = parseLimit(limit, 25, 100);
const result = await query(
`
WITH target_role AS (
SELECT
s.id AS shift_id,
s.tenant_id,
s.business_id,
s.clock_point_id,
sr.id AS shift_role_id,
sr.role_id,
sr.role_code,
sr.role_name
FROM shifts s
JOIN shift_roles sr ON sr.shift_id = s.id
WHERE s.tenant_id = $1
AND s.business_id = $2
AND s.id = $3
AND ($4::uuid IS NULL OR sr.id = $4)
ORDER BY sr.created_at ASC
LIMIT 1
)
SELECT
st.id AS "staffId",
st.full_name AS "fullName",
st.primary_role AS "primaryRole",
st.average_rating AS "averageRating",
st.rating_count AS "ratingCount",
COALESCE(dispatch.team_type, 'MARKETPLACE') AS "dispatchTeam",
COALESCE(dispatch.priority, 3) AS "dispatchPriority",
dispatch.hub_id AS "dispatchHubId"
FROM target_role tr
JOIN staffs st
ON st.tenant_id = tr.tenant_id
AND st.status = 'ACTIVE'
LEFT JOIN staff_blocks sb
ON sb.tenant_id = tr.tenant_id
AND sb.business_id = tr.business_id
AND sb.staff_id = st.id
LEFT JOIN LATERAL (
SELECT
dtm.team_type,
dtm.hub_id,
CASE dtm.team_type
WHEN 'CORE' THEN 1
WHEN 'CERTIFIED_LOCATION' THEN 2
ELSE 3
END AS priority
FROM dispatch_team_memberships dtm
WHERE dtm.tenant_id = tr.tenant_id
AND dtm.business_id = tr.business_id
AND dtm.staff_id = st.id
AND dtm.status = 'ACTIVE'
AND dtm.effective_at <= NOW()
AND (dtm.expires_at IS NULL OR dtm.expires_at > NOW())
AND (dtm.hub_id IS NULL OR dtm.hub_id = tr.clock_point_id)
ORDER BY
CASE dtm.team_type
WHEN 'CORE' THEN 1
WHEN 'CERTIFIED_LOCATION' THEN 2
ELSE 3
END ASC,
CASE WHEN dtm.hub_id = tr.clock_point_id THEN 0 ELSE 1 END ASC,
dtm.created_at ASC
LIMIT 1
) dispatch ON TRUE
WHERE sb.id IS NULL
AND (
st.primary_role = tr.role_code
OR EXISTS (
SELECT 1
FROM staff_roles str
WHERE str.staff_id = st.id
AND str.role_id = tr.role_id
)
)
AND NOT EXISTS (
SELECT 1
FROM assignments a
WHERE a.shift_id = tr.shift_id
AND a.staff_id = st.id
AND a.status IN ('ASSIGNED', 'ACCEPTED', 'SWAP_REQUESTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED')
)
ORDER BY "dispatchPriority" ASC, st.average_rating DESC, st.full_name ASC
LIMIT $5
`,
[context.tenant.tenantId, context.business.businessId, shiftId, roleId || null, safeLimit]
);
return result.rows;
}

View File

@@ -38,6 +38,8 @@ function createMobileQueryService() {
listCoverageByDate: async () => ([{ shiftId: 'coverage-1' }]),
listCoreTeam: async () => ([{ staffId: 'core-1' }]),
listCompletedShifts: async () => ([{ shiftId: 'completed-1' }]),
listCoverageDispatchCandidates: async () => ([{ staffId: 'dispatch-1' }]),
listCoverageDispatchTeams: async () => ([{ membershipId: 'dispatch-team-1' }]),
listEmergencyContacts: async () => ([{ contactId: 'ec-1' }]),
listFaqCategories: async () => ([{ id: 'faq-1', title: 'Clock in' }]),
listGeofenceIncidents: async () => ([{ incidentId: 'incident-1' }]),
@@ -61,6 +63,7 @@ function createMobileQueryService() {
listTaxForms: async () => ([{ formType: 'W4' }]),
listAttireChecklist: async () => ([{ documentId: 'attire-1' }]),
listTimeCardEntries: async () => ([{ entryId: 'tc-1' }]),
listSwapRequests: async () => ([{ swapRequestId: 'swap-1' }]),
listTodayShifts: async () => ([{ shiftId: 'today-1' }]),
listVendorRoles: async () => ([{ roleId: 'role-1' }]),
listVendors: async () => ([{ vendorId: 'vendor-1' }]),
@@ -138,6 +141,36 @@ test('GET /query/client/coverage/incidents returns injected incidents list', asy
assert.equal(res.body.items[0].incidentId, 'incident-1');
});
test('GET /query/client/coverage/swap-requests returns injected swap request list', async () => {
const app = createApp({ mobileQueryService: createMobileQueryService() });
const res = await request(app)
.get('/query/client/coverage/swap-requests?status=OPEN')
.set('Authorization', 'Bearer test-token');
assert.equal(res.status, 200);
assert.equal(res.body.items[0].swapRequestId, 'swap-1');
});
test('GET /query/client/coverage/dispatch-teams returns injected dispatch team memberships', async () => {
const app = createApp({ mobileQueryService: createMobileQueryService() });
const res = await request(app)
.get('/query/client/coverage/dispatch-teams')
.set('Authorization', 'Bearer test-token');
assert.equal(res.status, 200);
assert.equal(res.body.items[0].membershipId, 'dispatch-team-1');
});
test('GET /query/client/coverage/dispatch-candidates returns injected candidate list', async () => {
const app = createApp({ mobileQueryService: createMobileQueryService() });
const res = await request(app)
.get('/query/client/coverage/dispatch-candidates?shiftId=shift-1')
.set('Authorization', 'Bearer test-token');
assert.equal(res.status, 200);
assert.equal(res.body.items[0].staffId, 'dispatch-1');
});
test('GET /query/staff/profile/tax-forms returns injected tax forms', async () => {
const app = createApp({ mobileQueryService: createMobileQueryService() });
const res = await request(app)