feat(api): add staff order detail and compliance eligibility
This commit is contained in:
39
backend/query-api/src/lib/staff-order-eligibility.js
Normal file
39
backend/query-api/src/lib/staff-order-eligibility.js
Normal file
@@ -0,0 +1,39 @@
|
||||
function dedupeStrings(values = []) {
|
||||
return [...new Set(
|
||||
values
|
||||
.filter((value) => typeof value === 'string')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
)];
|
||||
}
|
||||
|
||||
export function dedupeDocumentNames(values = []) {
|
||||
return dedupeStrings(values);
|
||||
}
|
||||
|
||||
export function buildStaffOrderEligibilityBlockers({
|
||||
hasActiveWorkforce = true,
|
||||
businessBlockReason = null,
|
||||
hasExistingParticipation = false,
|
||||
missingDocumentNames = [],
|
||||
} = {}) {
|
||||
const blockers = [];
|
||||
|
||||
if (!hasActiveWorkforce) {
|
||||
blockers.push('Workforce profile is not active');
|
||||
}
|
||||
|
||||
if (businessBlockReason !== null && businessBlockReason !== undefined) {
|
||||
blockers.push(businessBlockReason
|
||||
? `You are blocked from working for this client: ${businessBlockReason}`
|
||||
: 'You are blocked from working for this client');
|
||||
}
|
||||
|
||||
if (hasExistingParticipation) {
|
||||
blockers.push('You already applied to or booked this order');
|
||||
}
|
||||
|
||||
blockers.push(...dedupeDocumentNames(missingDocumentNames).map((name) => `Missing required document: ${name}`));
|
||||
|
||||
return dedupeStrings(blockers);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
getForecastReport,
|
||||
getNoShowReport,
|
||||
getOrderReorderPreview,
|
||||
getStaffOrderDetail,
|
||||
listGeofenceIncidents,
|
||||
getReportSummary,
|
||||
getSavings,
|
||||
@@ -85,6 +86,7 @@ const defaultQueryService = {
|
||||
getForecastReport,
|
||||
getNoShowReport,
|
||||
getOrderReorderPreview,
|
||||
getStaffOrderDetail,
|
||||
listGeofenceIncidents,
|
||||
getReportSummary,
|
||||
getSavings,
|
||||
@@ -147,6 +149,17 @@ function requireQueryParam(name, value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireUuid(value, field) {
|
||||
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)) {
|
||||
const error = new Error(`${field} must be a UUID`);
|
||||
error.code = 'VALIDATION_ERROR';
|
||||
error.status = 400;
|
||||
error.details = { field };
|
||||
throw error;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function createMobileQueryRouter(queryService = defaultQueryService) {
|
||||
const router = Router();
|
||||
|
||||
@@ -566,6 +579,15 @@ export function createMobileQueryRouter(queryService = defaultQueryService) {
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/staff/orders/:orderId', requireAuth, requirePolicy('shifts.read', 'shift'), async (req, res, next) => {
|
||||
try {
|
||||
const data = await queryService.getStaffOrderDetail(req.actor.uid, requireUuid(req.params.orderId, 'orderId'));
|
||||
return res.status(200).json({ ...data, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/staff/shifts/pending', requireAuth, requirePolicy('shifts.read', 'shift'), async (req, res, next) => {
|
||||
try {
|
||||
const items = await queryService.listPendingAssignments(req.actor.uid);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AppError } from '../lib/errors.js';
|
||||
import { buildStaffOrderEligibilityBlockers, dedupeDocumentNames } from '../lib/staff-order-eligibility.js';
|
||||
import { FAQ_CATEGORIES } from '../data/faqs.js';
|
||||
import { query } from './db.js';
|
||||
import { requireClientContext, requireStaffContext } from './actor-context.js';
|
||||
@@ -98,6 +99,136 @@ function weekdayCodeInTimeZone(value, timeZone = 'UTC') {
|
||||
return label.slice(0, 3).toUpperCase();
|
||||
}
|
||||
|
||||
function formatCurrencyCents(cents) {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format((Number(cents || 0) / 100));
|
||||
}
|
||||
|
||||
function managerDisplayRole(manager) {
|
||||
if (manager?.role) return manager.role;
|
||||
if (manager?.businessRole === 'owner') return 'Business Owner';
|
||||
return 'Hub Manager';
|
||||
}
|
||||
|
||||
export function summarizeStaffOrderDetail({
|
||||
rows,
|
||||
managers = [],
|
||||
blockers = [],
|
||||
}) {
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
throw new AppError('NOT_FOUND', 'Order is not available for this staff worker', 404);
|
||||
}
|
||||
|
||||
const firstRow = rows[0];
|
||||
const timeZone = resolveTimeZone(firstRow.timezone);
|
||||
const orderedRows = [...rows].sort((left, right) => (
|
||||
new Date(left.startsAt).getTime() - new Date(right.startsAt).getTime()
|
||||
));
|
||||
const firstShift = orderedRows[0];
|
||||
const lastShift = orderedRows[orderedRows.length - 1];
|
||||
const daysOfWeek = [...new Set(orderedRows.map((row) => weekdayCodeInTimeZone(row.startsAt, timeZone)))];
|
||||
|
||||
const requiredWorkerCount = orderedRows.reduce(
|
||||
(sum, row) => sum + Number(row.requiredWorkerCount || 0),
|
||||
0
|
||||
);
|
||||
const filledCount = orderedRows.reduce(
|
||||
(sum, row) => sum + Number(row.filledCount || 0),
|
||||
0
|
||||
);
|
||||
const dispatchPriority = orderedRows.reduce(
|
||||
(min, row) => Math.min(min, Number(row.dispatchPriority || 3)),
|
||||
3
|
||||
);
|
||||
const dispatchTeam = dispatchPriority === 1
|
||||
? 'CORE'
|
||||
: dispatchPriority === 2
|
||||
? 'CERTIFIED_LOCATION'
|
||||
: 'MARKETPLACE';
|
||||
const hasOpenVacancy = orderedRows.some((row) => (
|
||||
row.shiftStatus === 'OPEN'
|
||||
&& Number(row.filledCount || 0) < Number(row.requiredWorkerCount || 0)
|
||||
));
|
||||
const allCancelled = orderedRows.every((row) => row.shiftStatus === 'CANCELLED');
|
||||
const allCompleted = orderedRows.every((row) => row.shiftStatus === 'COMPLETED');
|
||||
|
||||
let status = 'FILLED';
|
||||
if (firstRow.orderStatus === 'CANCELLED') status = 'CANCELLED';
|
||||
else if (firstRow.orderStatus === 'COMPLETED') status = 'COMPLETED';
|
||||
else if (hasOpenVacancy) status = 'OPEN';
|
||||
else if (allCancelled) status = 'CANCELLED';
|
||||
else if (allCompleted) status = 'COMPLETED';
|
||||
|
||||
const uniqueManagers = Array.from(
|
||||
new Map(
|
||||
managers.map((manager) => {
|
||||
const key = [
|
||||
manager.name || '',
|
||||
manager.phone || '',
|
||||
managerDisplayRole(manager),
|
||||
].join('|');
|
||||
return [key, {
|
||||
name: manager.name || null,
|
||||
phone: manager.phone || null,
|
||||
role: managerDisplayRole(manager),
|
||||
}];
|
||||
})
|
||||
).values()
|
||||
);
|
||||
|
||||
const uniqueBlockers = [...new Set(blockers.filter(Boolean))];
|
||||
|
||||
return {
|
||||
orderId: firstRow.orderId,
|
||||
orderType: firstRow.orderType,
|
||||
roleId: firstRow.roleId,
|
||||
roleCode: firstRow.roleCode,
|
||||
roleName: firstRow.roleName,
|
||||
clientName: firstRow.clientName,
|
||||
businessId: firstRow.businessId,
|
||||
instantBook: orderedRows.every((row) => Boolean(row.instantBook)),
|
||||
dispatchTeam,
|
||||
dispatchPriority,
|
||||
jobDescription: firstRow.jobDescription || `${firstRow.roleName} shift at ${firstRow.clientName}`,
|
||||
instructions: firstRow.instructions || null,
|
||||
status,
|
||||
schedule: {
|
||||
totalShifts: firstRow.orderType === 'PERMANENT' ? null : orderedRows.length,
|
||||
startDate: formatDateInTimeZone(firstShift.startsAt, timeZone),
|
||||
endDate: formatDateInTimeZone(lastShift.startsAt, timeZone),
|
||||
daysOfWeek,
|
||||
startTime: formatTimeInTimeZone(firstShift.startsAt, timeZone),
|
||||
endTime: formatTimeInTimeZone(firstShift.endsAt, timeZone),
|
||||
timezone: timeZone,
|
||||
firstShiftStartsAt: firstShift.startsAt,
|
||||
lastShiftEndsAt: lastShift.endsAt,
|
||||
},
|
||||
location: {
|
||||
name: firstRow.locationName || null,
|
||||
address: firstRow.locationAddress || null,
|
||||
latitude: firstRow.latitude == null ? null : Number(firstRow.latitude),
|
||||
longitude: firstRow.longitude == null ? null : Number(firstRow.longitude),
|
||||
},
|
||||
pay: {
|
||||
hourlyRateCents: Number(firstRow.hourlyRateCents || 0),
|
||||
hourlyRate: formatCurrencyCents(firstRow.hourlyRateCents || 0),
|
||||
},
|
||||
staffing: {
|
||||
requiredWorkerCount,
|
||||
filledCount,
|
||||
},
|
||||
managers: uniqueManagers,
|
||||
eligibility: {
|
||||
isEligible: uniqueBlockers.length === 0 && status === 'OPEN',
|
||||
blockers: uniqueBlockers,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function computeReliabilityScore({
|
||||
totalShifts,
|
||||
noShowCount,
|
||||
@@ -1232,6 +1363,187 @@ export async function listAvailableOrders(actorUid, { limit, search } = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function getStaffOrderDetail(actorUid, orderId) {
|
||||
const context = await requireStaffContext(actorUid);
|
||||
const roleCode = context.staff.primaryRole || 'BARISTA';
|
||||
const rowsResult = await query(
|
||||
`
|
||||
SELECT
|
||||
o.id AS "orderId",
|
||||
o.business_id AS "businessId",
|
||||
COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType",
|
||||
o.status AS "orderStatus",
|
||||
COALESCE(sr.role_id, rc.id) AS "roleId",
|
||||
COALESCE(sr.role_code, rc.code) AS "roleCode",
|
||||
COALESCE(sr.role_name, rc.name) AS "roleName",
|
||||
b.business_name AS "clientName",
|
||||
COALESCE((sr.metadata->>'instantBook')::boolean, FALSE) AS "instantBook",
|
||||
COALESCE(dispatch.team_type, 'MARKETPLACE') AS "dispatchTeam",
|
||||
COALESCE(dispatch.priority, 3) AS "dispatchPriority",
|
||||
o.description AS "jobDescription",
|
||||
o.notes AS instructions,
|
||||
s.id AS "shiftId",
|
||||
s.status AS "shiftStatus",
|
||||
s.starts_at AS "startsAt",
|
||||
s.ends_at AS "endsAt",
|
||||
COALESCE(s.timezone, 'UTC') AS timezone,
|
||||
COALESCE(cp.label, s.location_name, o.location_name) AS "locationName",
|
||||
COALESCE(s.location_address, cp.address, o.location_address) AS "locationAddress",
|
||||
COALESCE(s.latitude, cp.latitude, o.latitude) AS latitude,
|
||||
COALESCE(s.longitude, cp.longitude, o.longitude) AS longitude,
|
||||
COALESCE(sr.pay_rate_cents, 0)::INTEGER AS "hourlyRateCents",
|
||||
sr.workers_needed::INTEGER AS "requiredWorkerCount",
|
||||
sr.assigned_count::INTEGER AS "filledCount",
|
||||
cp.id AS "hubId"
|
||||
FROM orders o
|
||||
JOIN shifts s ON s.order_id = o.id
|
||||
JOIN shift_roles sr ON sr.shift_id = s.id
|
||||
LEFT JOIN roles_catalog rc
|
||||
ON rc.tenant_id = o.tenant_id
|
||||
AND (rc.id = sr.role_id OR (sr.role_id IS NULL AND rc.code = sr.role_code))
|
||||
JOIN businesses b ON b.id = o.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 = $4
|
||||
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 o.tenant_id = $1
|
||||
AND o.id = $2
|
||||
AND s.starts_at > NOW()
|
||||
AND COALESCE(sr.role_code, rc.code) = $3
|
||||
ORDER BY s.starts_at ASC
|
||||
`,
|
||||
[context.tenant.tenantId, orderId, roleCode, context.staff.staffId]
|
||||
);
|
||||
|
||||
if (rowsResult.rowCount === 0) {
|
||||
throw new AppError('NOT_FOUND', 'Order is not available for this staff worker', 404, {
|
||||
orderId,
|
||||
});
|
||||
}
|
||||
|
||||
const firstRow = rowsResult.rows[0];
|
||||
const hubIds = [...new Set(rowsResult.rows.map((row) => row.hubId).filter(Boolean))];
|
||||
|
||||
const [managerResult, blockedResult, participationResult, missingDocumentResult] = await Promise.all([
|
||||
hubIds.length === 0
|
||||
? Promise.resolve({ rows: [] })
|
||||
: query(
|
||||
`
|
||||
SELECT
|
||||
COALESCE(
|
||||
NULLIF(TRIM(CONCAT_WS(' ', bm.metadata->>'firstName', bm.metadata->>'lastName')), ''),
|
||||
u.display_name,
|
||||
u.email,
|
||||
bm.invited_email
|
||||
) AS name,
|
||||
COALESCE(u.phone, bm.metadata->>'phone') AS phone,
|
||||
bm.business_role AS "businessRole"
|
||||
FROM hub_managers hm
|
||||
JOIN business_memberships bm ON bm.id = hm.business_membership_id
|
||||
LEFT JOIN users u ON u.id = bm.user_id
|
||||
WHERE hm.tenant_id = $1
|
||||
AND hm.hub_id = ANY($2::uuid[])
|
||||
ORDER BY name ASC
|
||||
`,
|
||||
[context.tenant.tenantId, hubIds]
|
||||
),
|
||||
query(
|
||||
`
|
||||
SELECT reason
|
||||
FROM staff_blocks
|
||||
WHERE tenant_id = $1
|
||||
AND business_id = $2
|
||||
AND staff_id = $3
|
||||
LIMIT 1
|
||||
`,
|
||||
[context.tenant.tenantId, firstRow.businessId, context.staff.staffId]
|
||||
),
|
||||
query(
|
||||
`
|
||||
SELECT 1
|
||||
FROM shifts s
|
||||
JOIN shift_roles sr ON sr.shift_id = s.id
|
||||
LEFT JOIN applications a
|
||||
ON a.shift_role_id = sr.id
|
||||
AND a.staff_id = $3
|
||||
AND a.status IN ('PENDING', 'CONFIRMED', 'CHECKED_IN', 'COMPLETED')
|
||||
LEFT JOIN assignments ass
|
||||
ON ass.shift_role_id = sr.id
|
||||
AND ass.staff_id = $3
|
||||
AND ass.status IN ('ASSIGNED', 'ACCEPTED', 'SWAP_REQUESTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED')
|
||||
LEFT JOIN roles_catalog rc
|
||||
ON rc.tenant_id = s.tenant_id
|
||||
AND (rc.id = sr.role_id OR (sr.role_id IS NULL AND rc.code = sr.role_code))
|
||||
WHERE s.tenant_id = $1
|
||||
AND s.order_id = $2
|
||||
AND s.starts_at > NOW()
|
||||
AND COALESCE(sr.role_code, rc.code) = $4
|
||||
AND (a.id IS NOT NULL OR ass.id IS NOT NULL)
|
||||
LIMIT 1
|
||||
`,
|
||||
[context.tenant.tenantId, orderId, context.staff.staffId, roleCode]
|
||||
),
|
||||
query(
|
||||
`
|
||||
SELECT d.name
|
||||
FROM documents d
|
||||
WHERE d.tenant_id = $1
|
||||
AND d.required_for_role_code = $2
|
||||
AND d.document_type <> 'ATTIRE'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM staff_documents sd
|
||||
WHERE sd.tenant_id = d.tenant_id
|
||||
AND sd.staff_id = $3
|
||||
AND sd.document_id = d.id
|
||||
AND sd.status = 'VERIFIED'
|
||||
)
|
||||
ORDER BY d.name ASC
|
||||
`,
|
||||
[context.tenant.tenantId, firstRow.roleCode, context.staff.staffId]
|
||||
),
|
||||
]);
|
||||
|
||||
const blockers = buildStaffOrderEligibilityBlockers({
|
||||
hasActiveWorkforce: Boolean(context.staff.workforceId),
|
||||
businessBlockReason: blockedResult.rowCount > 0 ? blockedResult.rows[0].reason || null : null,
|
||||
hasExistingParticipation: participationResult.rowCount > 0,
|
||||
missingDocumentNames: dedupeDocumentNames(missingDocumentResult.rows.map((row) => row.name)),
|
||||
});
|
||||
|
||||
return summarizeStaffOrderDetail({
|
||||
rows: rowsResult.rows,
|
||||
managers: managerResult.rows.map((manager) => ({
|
||||
...manager,
|
||||
role: managerDisplayRole(manager),
|
||||
})),
|
||||
blockers,
|
||||
});
|
||||
}
|
||||
|
||||
export async function listOpenShifts(actorUid, { limit, search } = {}) {
|
||||
const context = await requireStaffContext(actorUid);
|
||||
const result = await query(
|
||||
|
||||
Reference in New Issue
Block a user