fix(api): close v2 mobile contract gaps

This commit is contained in:
zouantchaw
2026-03-17 22:37:45 +01:00
parent afcd896b47
commit 008dd7efb1
14 changed files with 1315 additions and 54 deletions

View File

@@ -196,6 +196,11 @@ export const shiftDecisionSchema = z.object({
reason: z.string().max(1000).optional(),
});
export const shiftSubmitApprovalSchema = z.object({
shiftId: z.string().uuid(),
note: z.string().max(2000).optional(),
});
export const staffClockInSchema = z.object({
assignmentId: z.string().uuid().optional(),
shiftId: z.string().uuid().optional(),

View File

@@ -28,6 +28,7 @@ import {
setupStaffProfile,
staffClockIn,
staffClockOut,
submitCompletedShiftForApproval,
submitLocationStreamBatch,
submitTaxForm,
unregisterClientPushToken,
@@ -70,6 +71,7 @@ import {
pushTokenRegisterSchema,
shiftApplySchema,
shiftDecisionSchema,
shiftSubmitApprovalSchema,
staffClockInSchema,
staffClockOutSchema,
staffLocationBatchSchema,
@@ -104,6 +106,7 @@ const defaultHandlers = {
setupStaffProfile,
staffClockIn,
staffClockOut,
submitCompletedShiftForApproval,
submitLocationStreamBatch,
submitTaxForm,
unregisterClientPushToken,
@@ -402,6 +405,14 @@ export function createMobileCommandsRouter(handlers = defaultHandlers) {
paramShape: (req) => ({ ...req.body, shiftId: req.params.shiftId }),
}));
router.post(...mobileCommand('/staff/shifts/:shiftId/submit-for-approval', {
schema: shiftSubmitApprovalSchema,
policyAction: 'staff.shifts.submit',
resource: 'shift',
handler: handlers.submitCompletedShiftForApproval,
paramShape: (req) => ({ ...req.body, shiftId: req.params.shiftId }),
}));
router.put(...mobileCommand('/staff/profile/personal-info', {
schema: personalInfoUpdateSchema,
policyAction: 'staff.profile.write',

View File

@@ -8,12 +8,14 @@ import { uploadLocationBatch } from './location-log-storage.js';
import { enqueueHubManagerAlert, enqueueUserAlert } from './notification-outbox.js';
import { registerPushToken, unregisterPushToken } from './notification-device-tokens.js';
import {
cancelOrder as cancelOrderCommand,
clockIn as clockInCommand,
clockOut as clockOutCommand,
createOrder as createOrderCommand,
} from './command-service.js';
const MOBILE_CANCELLABLE_ASSIGNMENT_STATUSES = ['ASSIGNED', 'ACCEPTED'];
const MOBILE_CANCELLABLE_APPLICATION_STATUSES = ['PENDING', 'CONFIRMED'];
function toIsoOrNull(value) {
return value ? new Date(value).toISOString() : null;
}
@@ -397,18 +399,153 @@ async function loadEditableOrderTemplate(actorUid, tenantId, businessId, orderId
WHERE o.id = $1
AND o.tenant_id = $2
AND o.business_id = $3
AND s.starts_at > NOW()
AND s.status NOT IN ('CANCELLED', 'COMPLETED')
GROUP BY o.id
`,
[orderId, tenantId, businessId]
);
if (result.rowCount === 0) {
throw new AppError('NOT_FOUND', 'Order not found for edit flow', 404, { orderId });
throw new AppError('ORDER_EDIT_BLOCKED', 'Order has no future shifts available for edit', 409, { orderId });
}
return result.rows[0];
}
async function cancelFutureOrderSlice(client, {
actorUid,
tenantId,
businessId,
orderId,
reason,
metadata = {},
}) {
const orderResult = await client.query(
`
SELECT id, order_number, status
FROM orders
WHERE id = $1
AND tenant_id = $2
AND business_id = $3
FOR UPDATE
`,
[orderId, tenantId, businessId]
);
if (orderResult.rowCount === 0) {
throw new AppError('NOT_FOUND', 'Order not found for cancel flow', 404, { orderId });
}
const order = orderResult.rows[0];
const futureShiftsResult = await client.query(
`
SELECT id
FROM shifts
WHERE order_id = $1
AND starts_at > NOW()
AND status NOT IN ('CANCELLED', 'COMPLETED')
ORDER BY starts_at ASC
FOR UPDATE
`,
[order.id]
);
if (futureShiftsResult.rowCount === 0) {
return {
orderId: order.id,
orderNumber: order.order_number,
status: order.status,
futureOnly: true,
cancelledShiftCount: 0,
alreadyCancelled: true,
};
}
const shiftIds = futureShiftsResult.rows.map((row) => row.id);
await client.query(
`
UPDATE orders
SET metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb,
updated_at = NOW()
WHERE id = $1
`,
[
order.id,
JSON.stringify({
futureCancellationReason: reason || null,
futureCancellationBy: actorUid,
futureCancellationAt: new Date().toISOString(),
...metadata,
}),
]
);
await client.query(
`
UPDATE shifts
SET status = 'CANCELLED',
updated_at = NOW()
WHERE id = ANY($1::uuid[])
`,
[shiftIds]
);
await client.query(
`
UPDATE assignments
SET status = 'CANCELLED',
updated_at = NOW()
WHERE shift_id = ANY($1::uuid[])
AND status = ANY($2::text[])
`,
[shiftIds, MOBILE_CANCELLABLE_ASSIGNMENT_STATUSES]
);
await client.query(
`
UPDATE applications
SET status = 'CANCELLED',
updated_at = NOW()
WHERE shift_id = ANY($1::uuid[])
AND status = ANY($2::text[])
`,
[shiftIds, MOBILE_CANCELLABLE_APPLICATION_STATUSES]
);
for (const shiftId of shiftIds) {
const roleIds = await client.query(
'SELECT id FROM shift_roles WHERE shift_id = $1',
[shiftId]
);
for (const role of roleIds.rows) {
await refreshShiftRoleCounts(client, role.id);
}
await refreshShiftCounts(client, shiftId);
}
await insertDomainEvent(client, {
tenantId,
aggregateType: 'order',
aggregateId: order.id,
eventType: 'ORDER_FUTURE_SLICE_CANCELLED',
actorUserId: actorUid,
payload: {
reason: reason || null,
shiftIds,
futureOnly: true,
},
});
return {
orderId: order.id,
orderNumber: order.order_number,
status: 'CANCELLED',
futureOnly: true,
cancelledShiftCount: shiftIds.length,
};
}
async function resolveStaffAssignmentForClock(actorUid, tenantId, payload, { requireOpenSession = false } = {}) {
const context = await requireStaffContext(actorUid);
if (payload.assignmentId) {
@@ -1547,11 +1684,31 @@ export async function createEditedOrderCopy(actor, payload) {
);
const templateShifts = Array.isArray(template.shifts) ? template.shifts : [];
const templatePositions = templateShifts.flatMap((shift) => (Array.isArray(shift.roles) ? shift.roles : []).map((role) => ({
...role,
startTime: role.startTime || shift.startTime,
endTime: role.endTime || shift.endTime,
})));
const templatePositions = Array.from(
templateShifts.reduce((deduped, shift) => {
for (const role of (Array.isArray(shift.roles) ? shift.roles : [])) {
const normalized = {
...role,
startTime: role.startTime || shift.startTime,
endTime: role.endTime || shift.endTime,
};
const key = [
normalized.roleId || '',
normalized.roleCode || '',
normalized.roleName || '',
normalized.startTime || '',
normalized.endTime || '',
normalized.workerCount ?? '',
normalized.payRateCents ?? '',
normalized.billRateCents ?? '',
].join('|');
if (!deduped.has(key)) {
deduped.set(key, normalized);
}
}
return deduped;
}, new Map()).values()
);
const firstShift = templateShifts[0] || {};
const lastShift = templateShifts[templateShifts.length - 1] || {};
const inferredOrderType = payload.orderType || template.metadata?.orderType || 'ONE_TIME';
@@ -1593,11 +1750,16 @@ export async function createEditedOrderCopy(actor, payload) {
export async function cancelClientOrder(actor, payload) {
const context = await requireClientContext(actor.uid);
return cancelOrderCommand(actor, {
tenantId: context.tenant.tenantId,
orderId: payload.orderId,
reason: payload.reason,
metadata: payload.metadata,
return withTransaction(async (client) => {
await ensureActorUser(client, actor);
return cancelFutureOrderSlice(client, {
actorUid: actor.uid,
tenantId: context.tenant.tenantId,
businessId: context.business.businessId,
orderId: payload.orderId,
reason: payload.reason,
metadata: payload.metadata,
});
});
}
@@ -2361,6 +2523,68 @@ export async function requestShiftSwap(actor, payload) {
});
}
export async function submitCompletedShiftForApproval(actor, payload) {
const context = await requireStaffContext(actor.uid);
return withTransaction(async (client) => {
await ensureActorUser(client, actor);
const assignment = await requireAnyAssignmentForActor(client, context.tenant.tenantId, payload.shiftId, actor.uid);
if (!['CHECKED_OUT', 'COMPLETED'].includes(assignment.status)) {
throw new AppError('INVALID_TIMESHEET_STATE', 'Only completed or checked-out shifts can be submitted for approval', 409, {
shiftId: payload.shiftId,
assignmentStatus: assignment.status,
});
}
const timesheetResult = await client.query(
`
INSERT INTO timesheets (
tenant_id,
assignment_id,
staff_id,
status,
metadata
)
VALUES ($1, $2, $3, 'SUBMITTED', $4::jsonb)
ON CONFLICT (assignment_id) DO UPDATE
SET status = CASE
WHEN timesheets.status IN ('APPROVED', 'PAID') THEN timesheets.status
ELSE 'SUBMITTED'
END,
metadata = COALESCE(timesheets.metadata, '{}'::jsonb) || EXCLUDED.metadata,
updated_at = NOW()
RETURNING id, status, metadata
`,
[
context.tenant.tenantId,
assignment.id,
assignment.staff_id,
JSON.stringify({
submittedAt: new Date().toISOString(),
submittedBy: actor.uid,
submissionNote: payload.note || null,
}),
]
);
await insertDomainEvent(client, {
tenantId: context.tenant.tenantId,
aggregateType: 'timesheet',
aggregateId: timesheetResult.rows[0].id,
eventType: 'TIMESHEET_SUBMITTED_FOR_APPROVAL',
actorUserId: actor.uid,
payload,
});
return {
assignmentId: assignment.id,
shiftId: assignment.shift_id,
timesheetId: timesheetResult.rows[0].id,
status: timesheetResult.rows[0].status,
submitted: timesheetResult.rows[0].status === 'SUBMITTED',
};
});
}
export async function setupStaffProfile(actor, payload) {
return withTransaction(async (client) => {
const scope = await resolveStaffOnboardingScope(client, actor.uid, payload.tenantId, payload.vendorId);

View File

@@ -77,6 +77,12 @@ function createMobileHandlers() {
assignmentId: payload.assignmentId || 'assignment-1',
status: 'CLOCK_OUT',
}),
submitCompletedShiftForApproval: async (_actor, payload) => ({
shiftId: payload.shiftId,
timesheetId: 'timesheet-1',
status: 'SUBMITTED',
submitted: true,
}),
submitLocationStreamBatch: async (_actor, payload) => ({
assignmentId: payload.assignmentId || 'assignment-1',
pointCount: payload.points.length,
@@ -342,3 +348,19 @@ test('POST /commands/staff/profile/bank-accounts uppercases account type', async
assert.equal(res.body.accountType, 'CHECKING');
assert.equal(res.body.last4, '7890');
});
test('POST /commands/staff/shifts/:shiftId/submit-for-approval injects shift id from params', async () => {
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
const res = await request(app)
.post('/commands/staff/shifts/77777777-7777-4777-8777-777777777777/submit-for-approval')
.set('Authorization', 'Bearer test-token')
.set('Idempotency-Key', 'shift-submit-approval-1')
.send({
note: 'Worked full shift and ready for approval',
});
assert.equal(res.status, 200);
assert.equal(res.body.shiftId, '77777777-7777-4777-8777-777777777777');
assert.equal(res.body.timesheetId, 'timesheet-1');
assert.equal(res.body.submitted, true);
});