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