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(),
|
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({
|
export const staffClockInSchema = z.object({
|
||||||
assignmentId: z.string().uuid().optional(),
|
assignmentId: z.string().uuid().optional(),
|
||||||
shiftId: z.string().uuid().optional(),
|
shiftId: z.string().uuid().optional(),
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
setupStaffProfile,
|
setupStaffProfile,
|
||||||
staffClockIn,
|
staffClockIn,
|
||||||
staffClockOut,
|
staffClockOut,
|
||||||
|
submitCompletedShiftForApproval,
|
||||||
submitLocationStreamBatch,
|
submitLocationStreamBatch,
|
||||||
submitTaxForm,
|
submitTaxForm,
|
||||||
unregisterClientPushToken,
|
unregisterClientPushToken,
|
||||||
@@ -70,6 +71,7 @@ import {
|
|||||||
pushTokenRegisterSchema,
|
pushTokenRegisterSchema,
|
||||||
shiftApplySchema,
|
shiftApplySchema,
|
||||||
shiftDecisionSchema,
|
shiftDecisionSchema,
|
||||||
|
shiftSubmitApprovalSchema,
|
||||||
staffClockInSchema,
|
staffClockInSchema,
|
||||||
staffClockOutSchema,
|
staffClockOutSchema,
|
||||||
staffLocationBatchSchema,
|
staffLocationBatchSchema,
|
||||||
@@ -104,6 +106,7 @@ const defaultHandlers = {
|
|||||||
setupStaffProfile,
|
setupStaffProfile,
|
||||||
staffClockIn,
|
staffClockIn,
|
||||||
staffClockOut,
|
staffClockOut,
|
||||||
|
submitCompletedShiftForApproval,
|
||||||
submitLocationStreamBatch,
|
submitLocationStreamBatch,
|
||||||
submitTaxForm,
|
submitTaxForm,
|
||||||
unregisterClientPushToken,
|
unregisterClientPushToken,
|
||||||
@@ -402,6 +405,14 @@ export function createMobileCommandsRouter(handlers = defaultHandlers) {
|
|||||||
paramShape: (req) => ({ ...req.body, shiftId: req.params.shiftId }),
|
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', {
|
router.put(...mobileCommand('/staff/profile/personal-info', {
|
||||||
schema: personalInfoUpdateSchema,
|
schema: personalInfoUpdateSchema,
|
||||||
policyAction: 'staff.profile.write',
|
policyAction: 'staff.profile.write',
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ import { uploadLocationBatch } from './location-log-storage.js';
|
|||||||
import { enqueueHubManagerAlert, enqueueUserAlert } from './notification-outbox.js';
|
import { enqueueHubManagerAlert, enqueueUserAlert } from './notification-outbox.js';
|
||||||
import { registerPushToken, unregisterPushToken } from './notification-device-tokens.js';
|
import { registerPushToken, unregisterPushToken } from './notification-device-tokens.js';
|
||||||
import {
|
import {
|
||||||
cancelOrder as cancelOrderCommand,
|
|
||||||
clockIn as clockInCommand,
|
clockIn as clockInCommand,
|
||||||
clockOut as clockOutCommand,
|
clockOut as clockOutCommand,
|
||||||
createOrder as createOrderCommand,
|
createOrder as createOrderCommand,
|
||||||
} from './command-service.js';
|
} from './command-service.js';
|
||||||
|
|
||||||
|
const MOBILE_CANCELLABLE_ASSIGNMENT_STATUSES = ['ASSIGNED', 'ACCEPTED'];
|
||||||
|
const MOBILE_CANCELLABLE_APPLICATION_STATUSES = ['PENDING', 'CONFIRMED'];
|
||||||
|
|
||||||
function toIsoOrNull(value) {
|
function toIsoOrNull(value) {
|
||||||
return value ? new Date(value).toISOString() : null;
|
return value ? new Date(value).toISOString() : null;
|
||||||
}
|
}
|
||||||
@@ -397,18 +399,153 @@ async function loadEditableOrderTemplate(actorUid, tenantId, businessId, orderId
|
|||||||
WHERE o.id = $1
|
WHERE o.id = $1
|
||||||
AND o.tenant_id = $2
|
AND o.tenant_id = $2
|
||||||
AND o.business_id = $3
|
AND o.business_id = $3
|
||||||
|
AND s.starts_at > NOW()
|
||||||
|
AND s.status NOT IN ('CANCELLED', 'COMPLETED')
|
||||||
GROUP BY o.id
|
GROUP BY o.id
|
||||||
`,
|
`,
|
||||||
[orderId, tenantId, businessId]
|
[orderId, tenantId, businessId]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.rowCount === 0) {
|
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];
|
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 } = {}) {
|
async function resolveStaffAssignmentForClock(actorUid, tenantId, payload, { requireOpenSession = false } = {}) {
|
||||||
const context = await requireStaffContext(actorUid);
|
const context = await requireStaffContext(actorUid);
|
||||||
if (payload.assignmentId) {
|
if (payload.assignmentId) {
|
||||||
@@ -1547,11 +1684,31 @@ export async function createEditedOrderCopy(actor, payload) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const templateShifts = Array.isArray(template.shifts) ? template.shifts : [];
|
const templateShifts = Array.isArray(template.shifts) ? template.shifts : [];
|
||||||
const templatePositions = templateShifts.flatMap((shift) => (Array.isArray(shift.roles) ? shift.roles : []).map((role) => ({
|
const templatePositions = Array.from(
|
||||||
|
templateShifts.reduce((deduped, shift) => {
|
||||||
|
for (const role of (Array.isArray(shift.roles) ? shift.roles : [])) {
|
||||||
|
const normalized = {
|
||||||
...role,
|
...role,
|
||||||
startTime: role.startTime || shift.startTime,
|
startTime: role.startTime || shift.startTime,
|
||||||
endTime: role.endTime || shift.endTime,
|
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 firstShift = templateShifts[0] || {};
|
||||||
const lastShift = templateShifts[templateShifts.length - 1] || {};
|
const lastShift = templateShifts[templateShifts.length - 1] || {};
|
||||||
const inferredOrderType = payload.orderType || template.metadata?.orderType || 'ONE_TIME';
|
const inferredOrderType = payload.orderType || template.metadata?.orderType || 'ONE_TIME';
|
||||||
@@ -1593,12 +1750,17 @@ export async function createEditedOrderCopy(actor, payload) {
|
|||||||
|
|
||||||
export async function cancelClientOrder(actor, payload) {
|
export async function cancelClientOrder(actor, payload) {
|
||||||
const context = await requireClientContext(actor.uid);
|
const context = await requireClientContext(actor.uid);
|
||||||
return cancelOrderCommand(actor, {
|
return withTransaction(async (client) => {
|
||||||
|
await ensureActorUser(client, actor);
|
||||||
|
return cancelFutureOrderSlice(client, {
|
||||||
|
actorUid: actor.uid,
|
||||||
tenantId: context.tenant.tenantId,
|
tenantId: context.tenant.tenantId,
|
||||||
|
businessId: context.business.businessId,
|
||||||
orderId: payload.orderId,
|
orderId: payload.orderId,
|
||||||
reason: payload.reason,
|
reason: payload.reason,
|
||||||
metadata: payload.metadata,
|
metadata: payload.metadata,
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function staffClockIn(actor, payload) {
|
export async function staffClockIn(actor, payload) {
|
||||||
@@ -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) {
|
export async function setupStaffProfile(actor, payload) {
|
||||||
return withTransaction(async (client) => {
|
return withTransaction(async (client) => {
|
||||||
const scope = await resolveStaffOnboardingScope(client, actor.uid, payload.tenantId, payload.vendorId);
|
const scope = await resolveStaffOnboardingScope(client, actor.uid, payload.tenantId, payload.vendorId);
|
||||||
|
|||||||
@@ -77,6 +77,12 @@ function createMobileHandlers() {
|
|||||||
assignmentId: payload.assignmentId || 'assignment-1',
|
assignmentId: payload.assignmentId || 'assignment-1',
|
||||||
status: 'CLOCK_OUT',
|
status: 'CLOCK_OUT',
|
||||||
}),
|
}),
|
||||||
|
submitCompletedShiftForApproval: async (_actor, payload) => ({
|
||||||
|
shiftId: payload.shiftId,
|
||||||
|
timesheetId: 'timesheet-1',
|
||||||
|
status: 'SUBMITTED',
|
||||||
|
submitted: true,
|
||||||
|
}),
|
||||||
submitLocationStreamBatch: async (_actor, payload) => ({
|
submitLocationStreamBatch: async (_actor, payload) => ({
|
||||||
assignmentId: payload.assignmentId || 'assignment-1',
|
assignmentId: payload.assignmentId || 'assignment-1',
|
||||||
pointCount: payload.points.length,
|
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.accountType, 'CHECKING');
|
||||||
assert.equal(res.body.last4, '7890');
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { rapidOrderParseSchema } from '../contracts/core/rapid-order-parse.js';
|
|||||||
import { rapidOrderTranscribeSchema } from '../contracts/core/rapid-order-transcribe.js';
|
import { rapidOrderTranscribeSchema } from '../contracts/core/rapid-order-transcribe.js';
|
||||||
import { reviewVerificationSchema } from '../contracts/core/review-verification.js';
|
import { reviewVerificationSchema } from '../contracts/core/review-verification.js';
|
||||||
import { invokeVertexModel } from '../services/llm.js';
|
import { invokeVertexModel } from '../services/llm.js';
|
||||||
|
import { requireTenantContext } from '../services/actor-context.js';
|
||||||
|
import { isDatabaseConfigured, query as dbQuery } from '../services/db.js';
|
||||||
import { checkLlmRateLimit } from '../services/llm-rate-limit.js';
|
import { checkLlmRateLimit } from '../services/llm-rate-limit.js';
|
||||||
import { parseRapidOrderText, transcribeRapidOrderAudio } from '../services/rapid-order.js';
|
import { parseRapidOrderText, transcribeRapidOrderAudio } from '../services/rapid-order.js';
|
||||||
import {
|
import {
|
||||||
@@ -26,6 +28,8 @@ import {
|
|||||||
} from '../services/verification-jobs.js';
|
} from '../services/verification-jobs.js';
|
||||||
import {
|
import {
|
||||||
deleteCertificate,
|
deleteCertificate,
|
||||||
|
finalizeCertificateUpload,
|
||||||
|
finalizeStaffDocumentUpload,
|
||||||
uploadCertificate,
|
uploadCertificate,
|
||||||
uploadProfilePhoto,
|
uploadProfilePhoto,
|
||||||
uploadStaffDocument,
|
uploadStaffDocument,
|
||||||
@@ -70,6 +74,35 @@ const certificateUploadMetaSchema = z.object({
|
|||||||
expiresAt: z.string().datetime().optional(),
|
expiresAt: z.string().datetime().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const finalizedDocumentUploadSchema = z.object({
|
||||||
|
fileUri: z.string().max(4096).optional(),
|
||||||
|
photoUrl: z.string().max(4096).optional(),
|
||||||
|
verificationId: z.string().min(1).max(120),
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
const finalizedCertificateUploadSchema = certificateUploadMetaSchema.extend({
|
||||||
|
fileUri: z.string().max(4096).optional(),
|
||||||
|
photoUrl: z.string().max(4096).optional(),
|
||||||
|
verificationId: z.string().min(1).max(120),
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
const rapidOrderProcessSchema = z.object({
|
||||||
|
text: z.string().trim().min(1).max(4000).optional(),
|
||||||
|
audioFileUri: z.string().startsWith('gs://').max(2048).optional(),
|
||||||
|
locale: z.string().trim().min(2).max(35).optional().default('en-US'),
|
||||||
|
promptHints: z.array(z.string().trim().min(1).max(80)).max(20).optional().default([]),
|
||||||
|
timezone: z.string().trim().min(1).max(80).optional(),
|
||||||
|
now: z.string().datetime({ offset: true }).optional(),
|
||||||
|
}).strict().superRefine((value, ctx) => {
|
||||||
|
if (!value.text && !value.audioFileUri) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: 'text or audioFileUri is required',
|
||||||
|
path: ['text'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function mockSignedUrl(fileUri, expiresInSeconds) {
|
function mockSignedUrl(fileUri, expiresInSeconds) {
|
||||||
const encoded = encodeURIComponent(fileUri);
|
const encoded = encodeURIComponent(fileUri);
|
||||||
const expiresAt = new Date(Date.now() + expiresInSeconds * 1000).toISOString();
|
const expiresAt = new Date(Date.now() + expiresInSeconds * 1000).toISOString();
|
||||||
@@ -114,6 +147,72 @@ function enforceLlmRateLimit(uid) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeRoleToken(value) {
|
||||||
|
return `${value || ''}`
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRapidOrderRoleCatalog(actorUid) {
|
||||||
|
if (!isDatabaseConfigured()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
let context;
|
||||||
|
try {
|
||||||
|
context = await requireTenantContext(actorUid);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await dbQuery(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
rc.id AS "roleId",
|
||||||
|
rc.code AS "roleCode",
|
||||||
|
rc.name AS "roleName",
|
||||||
|
COALESCE(MAX(sr.bill_rate_cents), 0)::INTEGER AS "hourlyRateCents"
|
||||||
|
FROM roles_catalog rc
|
||||||
|
LEFT JOIN shift_roles sr ON sr.role_id = rc.id
|
||||||
|
LEFT JOIN shifts s ON s.id = sr.shift_id
|
||||||
|
WHERE rc.tenant_id = $1
|
||||||
|
AND rc.status = 'ACTIVE'
|
||||||
|
GROUP BY rc.id
|
||||||
|
ORDER BY rc.name ASC
|
||||||
|
`,
|
||||||
|
[context.tenant.tenantId]
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function enrichRapidOrderPositions(positions, roleCatalog) {
|
||||||
|
const catalog = roleCatalog.map((role) => ({
|
||||||
|
...role,
|
||||||
|
normalizedName: normalizeRoleToken(role.roleName),
|
||||||
|
normalizedCode: normalizeRoleToken(role.roleCode),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return positions.map((position) => {
|
||||||
|
const normalizedRole = normalizeRoleToken(position.role);
|
||||||
|
const exact = catalog.find((role) => role.normalizedName === normalizedRole || role.normalizedCode === normalizedRole);
|
||||||
|
const fuzzy = exact || catalog.find((role) => (
|
||||||
|
role.normalizedName.includes(normalizedRole) || normalizedRole.includes(role.normalizedName)
|
||||||
|
));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...position,
|
||||||
|
roleId: fuzzy?.roleId || null,
|
||||||
|
roleCode: fuzzy?.roleCode || null,
|
||||||
|
roleName: fuzzy?.roleName || position.role,
|
||||||
|
hourlyRateCents: fuzzy?.hourlyRateCents || 0,
|
||||||
|
matched: Boolean(fuzzy),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function handleUploadFile(req, res, next) {
|
async function handleUploadFile(req, res, next) {
|
||||||
try {
|
try {
|
||||||
const file = req.file;
|
const file = req.file;
|
||||||
@@ -280,9 +379,74 @@ async function handleRapidOrderParse(req, res, next) {
|
|||||||
timezone: payload.timezone,
|
timezone: payload.timezone,
|
||||||
now: payload.now,
|
now: payload.now,
|
||||||
});
|
});
|
||||||
|
const roleCatalog = await loadRapidOrderRoleCatalog(req.actor.uid);
|
||||||
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
...result,
|
...result,
|
||||||
|
parsed: {
|
||||||
|
...result.parsed,
|
||||||
|
positions: enrichRapidOrderPositions(result.parsed.positions, roleCatalog),
|
||||||
|
},
|
||||||
|
catalog: {
|
||||||
|
roles: roleCatalog,
|
||||||
|
},
|
||||||
|
latencyMs: Date.now() - startedAt,
|
||||||
|
requestId: req.requestId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRapidOrderProcess(req, res, next) {
|
||||||
|
try {
|
||||||
|
const payload = parseBody(rapidOrderProcessSchema, req.body || {});
|
||||||
|
enforceLlmRateLimit(req.actor.uid);
|
||||||
|
|
||||||
|
let transcript = payload.text || null;
|
||||||
|
if (!transcript && payload.audioFileUri) {
|
||||||
|
validateFileUriAccess({
|
||||||
|
fileUri: payload.audioFileUri,
|
||||||
|
actorUid: req.actor.uid,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (requireRapidAudioFileExists() && !useMockUpload()) {
|
||||||
|
await ensureFileExistsForActor({
|
||||||
|
fileUri: payload.audioFileUri,
|
||||||
|
actorUid: req.actor.uid,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const transcribed = await transcribeRapidOrderAudio({
|
||||||
|
audioFileUri: payload.audioFileUri,
|
||||||
|
locale: payload.locale,
|
||||||
|
promptHints: payload.promptHints,
|
||||||
|
});
|
||||||
|
transcript = transcribed.transcript;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const parsed = await parseRapidOrderText({
|
||||||
|
text: transcript,
|
||||||
|
locale: payload.locale,
|
||||||
|
timezone: payload.timezone,
|
||||||
|
now: payload.now,
|
||||||
|
});
|
||||||
|
const roleCatalog = await loadRapidOrderRoleCatalog(req.actor.uid);
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
transcript,
|
||||||
|
parsed: {
|
||||||
|
...parsed.parsed,
|
||||||
|
positions: enrichRapidOrderPositions(parsed.parsed.positions, roleCatalog),
|
||||||
|
},
|
||||||
|
missingFields: parsed.missingFields,
|
||||||
|
warnings: parsed.warnings,
|
||||||
|
confidence: parsed.confidence,
|
||||||
|
catalog: {
|
||||||
|
roles: roleCatalog,
|
||||||
|
},
|
||||||
|
model: parsed.model,
|
||||||
latencyMs: Date.now() - startedAt,
|
latencyMs: Date.now() - startedAt,
|
||||||
requestId: req.requestId,
|
requestId: req.requestId,
|
||||||
});
|
});
|
||||||
@@ -341,9 +505,7 @@ async function handleProfilePhotoUpload(req, res, next) {
|
|||||||
async function handleDocumentUpload(req, res, next) {
|
async function handleDocumentUpload(req, res, next) {
|
||||||
try {
|
try {
|
||||||
const file = req.file;
|
const file = req.file;
|
||||||
if (!file) {
|
if (file) {
|
||||||
throw new AppError('INVALID_FILE', 'Missing file in multipart form data', 400);
|
|
||||||
}
|
|
||||||
const result = await uploadStaffDocument({
|
const result = await uploadStaffDocument({
|
||||||
actorUid: req.actor.uid,
|
actorUid: req.actor.uid,
|
||||||
documentId: req.params.documentId,
|
documentId: req.params.documentId,
|
||||||
@@ -354,6 +516,19 @@ async function handleDocumentUpload(req, res, next) {
|
|||||||
...result,
|
...result,
|
||||||
requestId: req.requestId,
|
requestId: req.requestId,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = parseBody(finalizedDocumentUploadSchema, req.body || {});
|
||||||
|
const result = await finalizeStaffDocumentUpload({
|
||||||
|
actorUid: req.actor.uid,
|
||||||
|
documentId: req.params.documentId,
|
||||||
|
routeType: 'document',
|
||||||
|
verificationId: payload.verificationId,
|
||||||
|
});
|
||||||
|
return res.status(200).json({
|
||||||
|
...result,
|
||||||
|
requestId: req.requestId,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return next(error);
|
return next(error);
|
||||||
}
|
}
|
||||||
@@ -362,9 +537,7 @@ async function handleDocumentUpload(req, res, next) {
|
|||||||
async function handleAttireUpload(req, res, next) {
|
async function handleAttireUpload(req, res, next) {
|
||||||
try {
|
try {
|
||||||
const file = req.file;
|
const file = req.file;
|
||||||
if (!file) {
|
if (file) {
|
||||||
throw new AppError('INVALID_FILE', 'Missing file in multipart form data', 400);
|
|
||||||
}
|
|
||||||
const result = await uploadStaffDocument({
|
const result = await uploadStaffDocument({
|
||||||
actorUid: req.actor.uid,
|
actorUid: req.actor.uid,
|
||||||
documentId: req.params.documentId,
|
documentId: req.params.documentId,
|
||||||
@@ -375,6 +548,19 @@ async function handleAttireUpload(req, res, next) {
|
|||||||
...result,
|
...result,
|
||||||
requestId: req.requestId,
|
requestId: req.requestId,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = parseBody(finalizedDocumentUploadSchema, req.body || {});
|
||||||
|
const result = await finalizeStaffDocumentUpload({
|
||||||
|
actorUid: req.actor.uid,
|
||||||
|
documentId: req.params.documentId,
|
||||||
|
routeType: 'attire',
|
||||||
|
verificationId: payload.verificationId,
|
||||||
|
});
|
||||||
|
return res.status(200).json({
|
||||||
|
...result,
|
||||||
|
requestId: req.requestId,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return next(error);
|
return next(error);
|
||||||
}
|
}
|
||||||
@@ -383,9 +569,7 @@ async function handleAttireUpload(req, res, next) {
|
|||||||
async function handleCertificateUpload(req, res, next) {
|
async function handleCertificateUpload(req, res, next) {
|
||||||
try {
|
try {
|
||||||
const file = req.file;
|
const file = req.file;
|
||||||
if (!file) {
|
if (file) {
|
||||||
throw new AppError('INVALID_FILE', 'Missing file in multipart form data', 400);
|
|
||||||
}
|
|
||||||
const payload = parseBody(certificateUploadMetaSchema, req.body || {});
|
const payload = parseBody(certificateUploadMetaSchema, req.body || {});
|
||||||
const result = await uploadCertificate({
|
const result = await uploadCertificate({
|
||||||
actorUid: req.actor.uid,
|
actorUid: req.actor.uid,
|
||||||
@@ -396,6 +580,17 @@ async function handleCertificateUpload(req, res, next) {
|
|||||||
...result,
|
...result,
|
||||||
requestId: req.requestId,
|
requestId: req.requestId,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = parseBody(finalizedCertificateUploadSchema, req.body || {});
|
||||||
|
const result = await finalizeCertificateUpload({
|
||||||
|
actorUid: req.actor.uid,
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
return res.status(200).json({
|
||||||
|
...result,
|
||||||
|
requestId: req.requestId,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return next(error);
|
return next(error);
|
||||||
}
|
}
|
||||||
@@ -464,9 +659,12 @@ export function createCoreRouter() {
|
|||||||
router.post('/invoke-llm', requireAuth, requirePolicy('core.invoke-llm', 'model'), handleInvokeLlm);
|
router.post('/invoke-llm', requireAuth, requirePolicy('core.invoke-llm', 'model'), handleInvokeLlm);
|
||||||
router.post('/rapid-orders/transcribe', requireAuth, requirePolicy('core.rapid-order.transcribe', 'model'), handleRapidOrderTranscribe);
|
router.post('/rapid-orders/transcribe', requireAuth, requirePolicy('core.rapid-order.transcribe', 'model'), handleRapidOrderTranscribe);
|
||||||
router.post('/rapid-orders/parse', requireAuth, requirePolicy('core.rapid-order.parse', 'model'), handleRapidOrderParse);
|
router.post('/rapid-orders/parse', requireAuth, requirePolicy('core.rapid-order.parse', 'model'), handleRapidOrderParse);
|
||||||
|
router.post('/rapid-orders/process', requireAuth, requirePolicy('core.rapid-order.process', 'model'), handleRapidOrderProcess);
|
||||||
router.post('/staff/profile/photo', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleProfilePhotoUpload);
|
router.post('/staff/profile/photo', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleProfilePhotoUpload);
|
||||||
router.post('/staff/documents/:documentId/upload', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleDocumentUpload);
|
router.post('/staff/documents/:documentId/upload', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleDocumentUpload);
|
||||||
|
router.put('/staff/documents/:documentId/upload', requireAuth, requirePolicy('core.upload', 'file'), handleDocumentUpload);
|
||||||
router.post('/staff/attire/:documentId/upload', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleAttireUpload);
|
router.post('/staff/attire/:documentId/upload', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleAttireUpload);
|
||||||
|
router.put('/staff/attire/:documentId/upload', requireAuth, requirePolicy('core.upload', 'file'), handleAttireUpload);
|
||||||
router.post('/staff/certificates/upload', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleCertificateUpload);
|
router.post('/staff/certificates/upload', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleCertificateUpload);
|
||||||
router.delete('/staff/certificates/:certificateType', requireAuth, requirePolicy('core.upload', 'file'), handleCertificateDelete);
|
router.delete('/staff/certificates/:certificateType', requireAuth, requirePolicy('core.upload', 'file'), handleCertificateDelete);
|
||||||
router.post('/verifications', requireAuth, requirePolicy('core.verification.create', 'verification'), handleCreateVerification);
|
router.post('/verifications', requireAuth, requirePolicy('core.verification.create', 'verification'), handleCreateVerification);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { AppError } from '../lib/errors.js';
|
|||||||
import { requireStaffContext } from './actor-context.js';
|
import { requireStaffContext } from './actor-context.js';
|
||||||
import { generateReadSignedUrl, uploadToGcs } from './storage.js';
|
import { generateReadSignedUrl, uploadToGcs } from './storage.js';
|
||||||
import { query, withTransaction } from './db.js';
|
import { query, withTransaction } from './db.js';
|
||||||
import { createVerificationJob } from './verification-jobs.js';
|
import { createVerificationJob, getVerificationJob } from './verification-jobs.js';
|
||||||
|
|
||||||
function safeName(value) {
|
function safeName(value) {
|
||||||
return `${value}`.replace(/[^a-zA-Z0-9._-]/g, '_');
|
return `${value}`.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||||
@@ -40,6 +40,53 @@ async function createPreviewUrl(actorUid, fileUri) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeDocumentStatusFromVerification(status) {
|
||||||
|
switch (`${status || ''}`.toUpperCase()) {
|
||||||
|
case 'AUTO_PASS':
|
||||||
|
case 'APPROVED':
|
||||||
|
return 'VERIFIED';
|
||||||
|
case 'AUTO_FAIL':
|
||||||
|
case 'REJECTED':
|
||||||
|
return 'REJECTED';
|
||||||
|
default:
|
||||||
|
return 'PENDING';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveVerificationBackedUpload({
|
||||||
|
actorUid,
|
||||||
|
verificationId,
|
||||||
|
subjectId,
|
||||||
|
allowedTypes,
|
||||||
|
}) {
|
||||||
|
if (!verificationId) {
|
||||||
|
throw new AppError('VALIDATION_ERROR', 'verificationId is required for finalized upload submission', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const verification = await getVerificationJob(verificationId, actorUid);
|
||||||
|
if (subjectId && verification.subjectId && verification.subjectId !== subjectId) {
|
||||||
|
throw new AppError('VALIDATION_ERROR', 'verificationId does not belong to the requested subject', 400, {
|
||||||
|
verificationId,
|
||||||
|
subjectId,
|
||||||
|
verificationSubjectId: verification.subjectId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowedTypes && allowedTypes.length > 0 && !allowedTypes.includes(verification.type)) {
|
||||||
|
throw new AppError('VALIDATION_ERROR', 'verificationId type does not match the requested upload', 400, {
|
||||||
|
verificationId,
|
||||||
|
verificationType: verification.type,
|
||||||
|
allowedTypes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
verification,
|
||||||
|
fileUri: verification.fileUri,
|
||||||
|
status: normalizeDocumentStatusFromVerification(verification.status),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function uploadProfilePhoto({ actorUid, file }) {
|
export async function uploadProfilePhoto({ actorUid, file }) {
|
||||||
const context = await requireStaffContext(actorUid);
|
const context = await requireStaffContext(actorUid);
|
||||||
const uploaded = await uploadActorFile({
|
const uploaded = await uploadActorFile({
|
||||||
@@ -163,6 +210,76 @@ export async function uploadStaffDocument({ actorUid, documentId, file, routeTyp
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function finalizeStaffDocumentUpload({
|
||||||
|
actorUid,
|
||||||
|
documentId,
|
||||||
|
routeType,
|
||||||
|
verificationId,
|
||||||
|
}) {
|
||||||
|
const context = await requireStaffContext(actorUid);
|
||||||
|
const document = await requireDocument(
|
||||||
|
context.tenant.tenantId,
|
||||||
|
documentId,
|
||||||
|
routeType === 'attire' ? ['ATTIRE'] : ['DOCUMENT', 'GOVERNMENT_ID', 'TAX_FORM']
|
||||||
|
);
|
||||||
|
|
||||||
|
const finalized = await resolveVerificationBackedUpload({
|
||||||
|
actorUid,
|
||||||
|
verificationId,
|
||||||
|
subjectId: documentId,
|
||||||
|
allowedTypes: routeType === 'attire'
|
||||||
|
? ['attire']
|
||||||
|
: ['government_id', 'document', 'tax_form'],
|
||||||
|
});
|
||||||
|
|
||||||
|
await withTransaction(async (client) => {
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
INSERT INTO staff_documents (
|
||||||
|
tenant_id,
|
||||||
|
staff_id,
|
||||||
|
document_id,
|
||||||
|
file_uri,
|
||||||
|
status,
|
||||||
|
verification_job_id,
|
||||||
|
metadata
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)
|
||||||
|
ON CONFLICT (staff_id, document_id) DO UPDATE
|
||||||
|
SET file_uri = EXCLUDED.file_uri,
|
||||||
|
status = EXCLUDED.status,
|
||||||
|
verification_job_id = EXCLUDED.verification_job_id,
|
||||||
|
metadata = COALESCE(staff_documents.metadata, '{}'::jsonb) || EXCLUDED.metadata,
|
||||||
|
updated_at = NOW()
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
context.tenant.tenantId,
|
||||||
|
context.staff.staffId,
|
||||||
|
document.id,
|
||||||
|
finalized.fileUri,
|
||||||
|
finalized.status,
|
||||||
|
finalized.verification.verificationId,
|
||||||
|
JSON.stringify({
|
||||||
|
verificationStatus: finalized.verification.status,
|
||||||
|
routeType,
|
||||||
|
finalizedFromVerification: true,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const preview = await createPreviewUrl(actorUid, finalized.fileUri);
|
||||||
|
return {
|
||||||
|
documentId: document.id,
|
||||||
|
documentType: document.document_type,
|
||||||
|
fileUri: finalized.fileUri,
|
||||||
|
signedUrl: preview.signedUrl,
|
||||||
|
expiresAt: preview.expiresAt,
|
||||||
|
verification: finalized.verification,
|
||||||
|
status: finalized.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function uploadCertificate({ actorUid, file, payload }) {
|
export async function uploadCertificate({ actorUid, file, payload }) {
|
||||||
const context = await requireStaffContext(actorUid);
|
const context = await requireStaffContext(actorUid);
|
||||||
const uploaded = await uploadActorFile({
|
const uploaded = await uploadActorFile({
|
||||||
@@ -236,6 +353,106 @@ export async function uploadCertificate({ actorUid, file, payload }) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function finalizeCertificateUpload({ actorUid, payload }) {
|
||||||
|
const context = await requireStaffContext(actorUid);
|
||||||
|
const finalized = await resolveVerificationBackedUpload({
|
||||||
|
actorUid,
|
||||||
|
verificationId: payload.verificationId,
|
||||||
|
subjectId: payload.certificateType,
|
||||||
|
allowedTypes: ['certification'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const certificateResult = await withTransaction(async (client) => {
|
||||||
|
const existing = await client.query(
|
||||||
|
`
|
||||||
|
SELECT id
|
||||||
|
FROM certificates
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
AND staff_id = $2
|
||||||
|
AND certificate_type = $3
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
FOR UPDATE
|
||||||
|
`,
|
||||||
|
[context.tenant.tenantId, context.staff.staffId, payload.certificateType]
|
||||||
|
);
|
||||||
|
|
||||||
|
const metadata = JSON.stringify({
|
||||||
|
name: payload.name,
|
||||||
|
issuer: payload.issuer || null,
|
||||||
|
verificationStatus: finalized.verification.status,
|
||||||
|
finalizedFromVerification: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing.rowCount > 0) {
|
||||||
|
return client.query(
|
||||||
|
`
|
||||||
|
UPDATE certificates
|
||||||
|
SET certificate_number = $2,
|
||||||
|
expires_at = $3,
|
||||||
|
status = $4,
|
||||||
|
file_uri = $5,
|
||||||
|
verification_job_id = $6,
|
||||||
|
metadata = COALESCE(metadata, '{}'::jsonb) || $7::jsonb,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
existing.rows[0].id,
|
||||||
|
payload.certificateNumber || null,
|
||||||
|
payload.expiresAt || null,
|
||||||
|
finalized.status,
|
||||||
|
finalized.fileUri,
|
||||||
|
finalized.verification.verificationId,
|
||||||
|
metadata,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return client.query(
|
||||||
|
`
|
||||||
|
INSERT INTO certificates (
|
||||||
|
tenant_id,
|
||||||
|
staff_id,
|
||||||
|
certificate_type,
|
||||||
|
certificate_number,
|
||||||
|
issued_at,
|
||||||
|
expires_at,
|
||||||
|
status,
|
||||||
|
file_uri,
|
||||||
|
verification_job_id,
|
||||||
|
metadata
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, NOW(), $5, $6, $7, $8, $9::jsonb)
|
||||||
|
RETURNING id
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
context.tenant.tenantId,
|
||||||
|
context.staff.staffId,
|
||||||
|
payload.certificateType,
|
||||||
|
payload.certificateNumber || null,
|
||||||
|
payload.expiresAt || null,
|
||||||
|
finalized.status,
|
||||||
|
finalized.fileUri,
|
||||||
|
finalized.verification.verificationId,
|
||||||
|
metadata,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const preview = await createPreviewUrl(actorUid, finalized.fileUri);
|
||||||
|
return {
|
||||||
|
certificateId: certificateResult.rows[0].id,
|
||||||
|
certificateType: payload.certificateType,
|
||||||
|
fileUri: finalized.fileUri,
|
||||||
|
signedUrl: preview.signedUrl,
|
||||||
|
expiresAt: preview.expiresAt,
|
||||||
|
verification: finalized.verification,
|
||||||
|
status: finalized.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteCertificate({ actorUid, certificateType }) {
|
export async function deleteCertificate({ actorUid, certificateType }) {
|
||||||
const context = await requireStaffContext(actorUid);
|
const context = await requireStaffContext(actorUid);
|
||||||
const result = await query(
|
const result = await query(
|
||||||
|
|||||||
@@ -267,6 +267,25 @@ test('POST /core/rapid-orders/parse rejects unknown fields', async () => {
|
|||||||
assert.equal(res.body.code, 'VALIDATION_ERROR');
|
assert.equal(res.body.code, 'VALIDATION_ERROR');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('POST /core/rapid-orders/process accepts text-only flow', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/core/rapid-orders/process')
|
||||||
|
.set('Authorization', 'Bearer test-token')
|
||||||
|
.send({
|
||||||
|
text: 'Need 2 servers ASAP for 4 hours',
|
||||||
|
locale: 'en-US',
|
||||||
|
timezone: 'America/New_York',
|
||||||
|
now: '2026-02-27T12:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(typeof res.body.transcript, 'string');
|
||||||
|
assert.equal(res.body.parsed.orderType, 'ONE_TIME');
|
||||||
|
assert.equal(Array.isArray(res.body.parsed.positions), true);
|
||||||
|
assert.equal(Array.isArray(res.body.catalog.roles), true);
|
||||||
|
});
|
||||||
|
|
||||||
test('POST /core/rapid-orders/parse enforces per-user model rate limit', async () => {
|
test('POST /core/rapid-orders/parse enforces per-user model rate limit', async () => {
|
||||||
process.env.LLM_RATE_LIMIT_PER_MINUTE = '1';
|
process.env.LLM_RATE_LIMIT_PER_MINUTE = '1';
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
|
|||||||
@@ -171,17 +171,32 @@ export async function listRecentReorders(actorUid, limit) {
|
|||||||
o.id,
|
o.id,
|
||||||
o.title,
|
o.title,
|
||||||
o.starts_at AS "date",
|
o.starts_at AS "date",
|
||||||
COALESCE(cp.label, o.location_name) AS "hubName",
|
MAX(COALESCE(cp.label, o.location_name)) AS "hubName",
|
||||||
COALESCE(COUNT(sr.id), 0)::INTEGER AS "positionCount",
|
MAX(b.business_name) AS "clientName",
|
||||||
COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType"
|
COALESCE(SUM(sr.workers_needed), 0)::INTEGER AS "positionCount",
|
||||||
|
COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType",
|
||||||
|
COALESCE(ROUND(AVG(sr.bill_rate_cents))::INTEGER, 0) AS "hourlyRateCents",
|
||||||
|
COALESCE(
|
||||||
|
SUM(
|
||||||
|
sr.bill_rate_cents
|
||||||
|
* sr.workers_needed
|
||||||
|
* GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0)
|
||||||
|
),
|
||||||
|
0
|
||||||
|
)::BIGINT AS "totalPriceCents",
|
||||||
|
COALESCE(
|
||||||
|
SUM(GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0)),
|
||||||
|
0
|
||||||
|
)::NUMERIC(12,2) AS hours
|
||||||
FROM orders o
|
FROM orders o
|
||||||
|
JOIN businesses b ON b.id = o.business_id
|
||||||
LEFT JOIN shifts s ON s.order_id = o.id
|
LEFT JOIN shifts s ON s.order_id = o.id
|
||||||
LEFT JOIN shift_roles sr ON sr.shift_id = s.id
|
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 clock_points cp ON cp.id = s.clock_point_id
|
||||||
WHERE o.tenant_id = $1
|
WHERE o.tenant_id = $1
|
||||||
AND o.business_id = $2
|
AND o.business_id = $2
|
||||||
AND o.status IN ('COMPLETED', 'ACTIVE', 'FILLED')
|
AND o.status IN ('COMPLETED', 'ACTIVE', 'FILLED')
|
||||||
GROUP BY o.id, cp.label
|
GROUP BY o.id
|
||||||
ORDER BY o.starts_at DESC NULLS LAST
|
ORDER BY o.starts_at DESC NULLS LAST
|
||||||
LIMIT $3
|
LIMIT $3
|
||||||
`,
|
`,
|
||||||
@@ -520,15 +535,33 @@ export async function listOrderItemsByDateRange(actorUid, { startDate, endDate }
|
|||||||
sr.id AS "itemId",
|
sr.id AS "itemId",
|
||||||
o.id AS "orderId",
|
o.id AS "orderId",
|
||||||
COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType",
|
COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType",
|
||||||
|
o.title AS "eventName",
|
||||||
|
b.business_name AS "clientName",
|
||||||
|
sr.role_name AS title,
|
||||||
sr.role_name AS "roleName",
|
sr.role_name AS "roleName",
|
||||||
s.starts_at AS date,
|
to_char(s.starts_at AT TIME ZONE 'UTC', 'YYYY-MM-DD') AS date,
|
||||||
s.starts_at AS "startsAt",
|
s.starts_at AS "startsAt",
|
||||||
s.ends_at AS "endsAt",
|
s.ends_at AS "endsAt",
|
||||||
|
to_char(s.starts_at AT TIME ZONE 'UTC', 'HH24:MI') AS "startTime",
|
||||||
|
to_char(s.ends_at AT TIME ZONE 'UTC', 'HH24:MI') AS "endTime",
|
||||||
sr.workers_needed AS "requiredWorkerCount",
|
sr.workers_needed AS "requiredWorkerCount",
|
||||||
sr.assigned_count AS "filledCount",
|
sr.assigned_count AS "filledCount",
|
||||||
sr.bill_rate_cents AS "hourlyRateCents",
|
sr.bill_rate_cents AS "hourlyRateCents",
|
||||||
|
ROUND(COALESCE(sr.bill_rate_cents, 0)::numeric / 100, 2) AS "hourlyRate",
|
||||||
|
GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0)::NUMERIC(12,2) AS hours,
|
||||||
(sr.bill_rate_cents * sr.workers_needed)::BIGINT AS "totalCostCents",
|
(sr.bill_rate_cents * sr.workers_needed)::BIGINT AS "totalCostCents",
|
||||||
|
ROUND(
|
||||||
|
(
|
||||||
|
sr.bill_rate_cents
|
||||||
|
* sr.workers_needed
|
||||||
|
* GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0)
|
||||||
|
)::numeric / 100,
|
||||||
|
2
|
||||||
|
) AS "totalValue",
|
||||||
COALESCE(cp.label, s.location_name) AS "locationName",
|
COALESCE(cp.label, s.location_name) AS "locationName",
|
||||||
|
COALESCE(s.location_address, cp.address) AS "locationAddress",
|
||||||
|
hm.business_membership_id AS "hubManagerId",
|
||||||
|
COALESCE(u.display_name, u.email) AS "hubManagerName",
|
||||||
s.status,
|
s.status,
|
||||||
COALESCE(
|
COALESCE(
|
||||||
json_agg(
|
json_agg(
|
||||||
@@ -544,14 +577,34 @@ export async function listOrderItemsByDateRange(actorUid, { startDate, endDate }
|
|||||||
FROM shift_roles sr
|
FROM shift_roles sr
|
||||||
JOIN shifts s ON s.id = sr.shift_id
|
JOIN shifts s ON s.id = sr.shift_id
|
||||||
JOIN orders o ON o.id = s.order_id
|
JOIN orders o ON o.id = s.order_id
|
||||||
|
JOIN businesses b ON b.id = o.business_id
|
||||||
LEFT JOIN clock_points cp ON cp.id = s.clock_point_id
|
LEFT JOIN clock_points cp ON cp.id = s.clock_point_id
|
||||||
LEFT JOIN assignments a ON a.shift_role_id = sr.id
|
LEFT JOIN assignments a ON a.shift_role_id = sr.id
|
||||||
LEFT JOIN staffs st ON st.id = a.staff_id
|
LEFT JOIN staffs st ON st.id = a.staff_id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT business_membership_id
|
||||||
|
FROM hub_managers
|
||||||
|
WHERE tenant_id = o.tenant_id
|
||||||
|
AND hub_id = s.clock_point_id
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
) hm ON TRUE
|
||||||
|
LEFT JOIN business_memberships bm ON bm.id = hm.business_membership_id
|
||||||
|
LEFT JOIN users u ON u.id = bm.user_id
|
||||||
WHERE o.tenant_id = $1
|
WHERE o.tenant_id = $1
|
||||||
AND o.business_id = $2
|
AND o.business_id = $2
|
||||||
AND s.starts_at >= $3::timestamptz
|
AND s.starts_at >= $3::timestamptz
|
||||||
AND s.starts_at <= $4::timestamptz
|
AND s.starts_at <= $4::timestamptz
|
||||||
GROUP BY sr.id, o.id, s.id, cp.label
|
GROUP BY
|
||||||
|
sr.id,
|
||||||
|
o.id,
|
||||||
|
s.id,
|
||||||
|
cp.label,
|
||||||
|
cp.address,
|
||||||
|
b.business_name,
|
||||||
|
hm.business_membership_id,
|
||||||
|
u.display_name,
|
||||||
|
u.email
|
||||||
ORDER BY s.starts_at ASC, sr.role_name ASC
|
ORDER BY s.starts_at ASC, sr.role_name ASC
|
||||||
`,
|
`,
|
||||||
[context.tenant.tenantId, context.business.businessId, range.start, range.end]
|
[context.tenant.tenantId, context.business.businessId, range.start, range.end]
|
||||||
@@ -633,6 +686,23 @@ export async function listTodayShifts(actorUid) {
|
|||||||
COALESCE(s.title, sr.role_name || ' shift') AS title,
|
COALESCE(s.title, sr.role_name || ' shift') AS title,
|
||||||
b.business_name AS "clientName",
|
b.business_name AS "clientName",
|
||||||
ROUND(COALESCE(sr.pay_rate_cents, 0)::numeric / 100, 2) AS "hourlyRate",
|
ROUND(COALESCE(sr.pay_rate_cents, 0)::numeric / 100, 2) AS "hourlyRate",
|
||||||
|
COALESCE(sr.pay_rate_cents, 0)::INTEGER AS "hourlyRateCents",
|
||||||
|
ROUND(
|
||||||
|
(
|
||||||
|
COALESCE(sr.pay_rate_cents, 0)
|
||||||
|
* GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0)
|
||||||
|
)::numeric / 100,
|
||||||
|
2
|
||||||
|
) AS "totalRate",
|
||||||
|
COALESCE(
|
||||||
|
ROUND(
|
||||||
|
(
|
||||||
|
COALESCE(sr.pay_rate_cents, 0)
|
||||||
|
* GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0)
|
||||||
|
)
|
||||||
|
)::INTEGER,
|
||||||
|
0
|
||||||
|
) AS "totalRateCents",
|
||||||
sr.role_name AS "roleName",
|
sr.role_name AS "roleName",
|
||||||
COALESCE(cp.label, s.location_name) AS location,
|
COALESCE(cp.label, s.location_name) AS location,
|
||||||
COALESCE(s.location_address, cp.address) AS "locationAddress",
|
COALESCE(s.location_address, cp.address) AS "locationAddress",
|
||||||
@@ -656,7 +726,7 @@ export async function listTodayShifts(actorUid) {
|
|||||||
AND a.staff_id = $2
|
AND a.staff_id = $2
|
||||||
AND s.starts_at >= $3::timestamptz
|
AND s.starts_at >= $3::timestamptz
|
||||||
AND s.starts_at < $4::timestamptz
|
AND s.starts_at < $4::timestamptz
|
||||||
AND a.status IN ('ASSIGNED', 'ACCEPTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED')
|
AND a.status IN ('ASSIGNED', 'ACCEPTED', 'SWAP_REQUESTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED')
|
||||||
ORDER BY ABS(EXTRACT(EPOCH FROM (s.starts_at - NOW()))) ASC
|
ORDER BY ABS(EXTRACT(EPOCH FROM (s.starts_at - NOW()))) ASC
|
||||||
`,
|
`,
|
||||||
[context.tenant.tenantId, context.staff.staffId, from, to]
|
[context.tenant.tenantId, context.staff.staffId, from, to]
|
||||||
@@ -767,24 +837,43 @@ export async function listAssignedShifts(actorUid, { startDate, endDate }) {
|
|||||||
SELECT
|
SELECT
|
||||||
a.id AS "assignmentId",
|
a.id AS "assignmentId",
|
||||||
s.id AS "shiftId",
|
s.id AS "shiftId",
|
||||||
|
b.business_name AS "clientName",
|
||||||
sr.role_name AS "roleName",
|
sr.role_name AS "roleName",
|
||||||
COALESCE(cp.label, s.location_name) AS location,
|
COALESCE(cp.label, s.location_name) AS location,
|
||||||
s.starts_at AS date,
|
s.starts_at AS date,
|
||||||
s.starts_at AS "startTime",
|
s.starts_at AS "startTime",
|
||||||
s.ends_at AS "endTime",
|
s.ends_at AS "endTime",
|
||||||
sr.pay_rate_cents AS "hourlyRateCents",
|
sr.pay_rate_cents AS "hourlyRateCents",
|
||||||
|
ROUND(COALESCE(sr.pay_rate_cents, 0)::numeric / 100, 2) AS "hourlyRate",
|
||||||
|
COALESCE(
|
||||||
|
ROUND(
|
||||||
|
(
|
||||||
|
COALESCE(sr.pay_rate_cents, 0)
|
||||||
|
* GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0)
|
||||||
|
)
|
||||||
|
)::INTEGER,
|
||||||
|
0
|
||||||
|
) AS "totalRateCents",
|
||||||
|
ROUND(
|
||||||
|
(
|
||||||
|
COALESCE(sr.pay_rate_cents, 0)
|
||||||
|
* GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0)
|
||||||
|
)::numeric / 100,
|
||||||
|
2
|
||||||
|
) AS "totalRate",
|
||||||
COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType",
|
COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType",
|
||||||
a.status
|
a.status
|
||||||
FROM assignments a
|
FROM assignments a
|
||||||
JOIN shifts s ON s.id = a.shift_id
|
JOIN shifts s ON s.id = a.shift_id
|
||||||
JOIN shift_roles sr ON sr.id = a.shift_role_id
|
JOIN shift_roles sr ON sr.id = a.shift_role_id
|
||||||
JOIN orders o ON o.id = s.order_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 clock_points cp ON cp.id = s.clock_point_id
|
||||||
WHERE a.tenant_id = $1
|
WHERE a.tenant_id = $1
|
||||||
AND a.staff_id = $2
|
AND a.staff_id = $2
|
||||||
AND s.starts_at >= $3::timestamptz
|
AND s.starts_at >= $3::timestamptz
|
||||||
AND s.starts_at <= $4::timestamptz
|
AND s.starts_at <= $4::timestamptz
|
||||||
AND a.status IN ('ASSIGNED', 'ACCEPTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED')
|
AND a.status IN ('ASSIGNED', 'ACCEPTED', 'SWAP_REQUESTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED')
|
||||||
ORDER BY s.starts_at ASC
|
ORDER BY s.starts_at ASC
|
||||||
`,
|
`,
|
||||||
[context.tenant.tenantId, context.staff.staffId, range.start, range.end]
|
[context.tenant.tenantId, context.staff.staffId, range.start, range.end]
|
||||||
@@ -800,18 +889,37 @@ export async function listOpenShifts(actorUid, { limit, search } = {}) {
|
|||||||
SELECT
|
SELECT
|
||||||
s.id AS "shiftId",
|
s.id AS "shiftId",
|
||||||
sr.id AS "roleId",
|
sr.id AS "roleId",
|
||||||
|
b.business_name AS "clientName",
|
||||||
sr.role_name AS "roleName",
|
sr.role_name AS "roleName",
|
||||||
COALESCE(cp.label, s.location_name) AS location,
|
COALESCE(cp.label, s.location_name) AS location,
|
||||||
s.starts_at AS date,
|
s.starts_at AS date,
|
||||||
s.starts_at AS "startTime",
|
s.starts_at AS "startTime",
|
||||||
s.ends_at AS "endTime",
|
s.ends_at AS "endTime",
|
||||||
sr.pay_rate_cents AS "hourlyRateCents",
|
sr.pay_rate_cents AS "hourlyRateCents",
|
||||||
|
ROUND(COALESCE(sr.pay_rate_cents, 0)::numeric / 100, 2) AS "hourlyRate",
|
||||||
|
COALESCE(
|
||||||
|
ROUND(
|
||||||
|
(
|
||||||
|
COALESCE(sr.pay_rate_cents, 0)
|
||||||
|
* GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0)
|
||||||
|
)
|
||||||
|
)::INTEGER,
|
||||||
|
0
|
||||||
|
) AS "totalRateCents",
|
||||||
|
ROUND(
|
||||||
|
(
|
||||||
|
COALESCE(sr.pay_rate_cents, 0)
|
||||||
|
* GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0)
|
||||||
|
)::numeric / 100,
|
||||||
|
2
|
||||||
|
) AS "totalRate",
|
||||||
COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType",
|
COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType",
|
||||||
FALSE AS "instantBook",
|
FALSE AS "instantBook",
|
||||||
sr.workers_needed AS "requiredWorkerCount"
|
sr.workers_needed AS "requiredWorkerCount"
|
||||||
FROM shifts s
|
FROM shifts s
|
||||||
JOIN shift_roles sr ON sr.shift_id = s.id
|
JOIN shift_roles sr ON sr.shift_id = s.id
|
||||||
JOIN orders o ON o.id = s.order_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 clock_points cp ON cp.id = s.clock_point_id
|
||||||
WHERE s.tenant_id = $1
|
WHERE s.tenant_id = $1
|
||||||
AND s.status = 'OPEN'
|
AND s.status = 'OPEN'
|
||||||
@@ -829,12 +937,30 @@ export async function listOpenShifts(actorUid, { limit, search } = {}) {
|
|||||||
SELECT
|
SELECT
|
||||||
s.id AS "shiftId",
|
s.id AS "shiftId",
|
||||||
sr.id AS "roleId",
|
sr.id AS "roleId",
|
||||||
|
b.business_name AS "clientName",
|
||||||
sr.role_name AS "roleName",
|
sr.role_name AS "roleName",
|
||||||
COALESCE(cp.label, s.location_name) AS location,
|
COALESCE(cp.label, s.location_name) AS location,
|
||||||
s.starts_at AS date,
|
s.starts_at AS date,
|
||||||
s.starts_at AS "startTime",
|
s.starts_at AS "startTime",
|
||||||
s.ends_at AS "endTime",
|
s.ends_at AS "endTime",
|
||||||
sr.pay_rate_cents AS "hourlyRateCents",
|
sr.pay_rate_cents AS "hourlyRateCents",
|
||||||
|
ROUND(COALESCE(sr.pay_rate_cents, 0)::numeric / 100, 2) AS "hourlyRate",
|
||||||
|
COALESCE(
|
||||||
|
ROUND(
|
||||||
|
(
|
||||||
|
COALESCE(sr.pay_rate_cents, 0)
|
||||||
|
* GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0)
|
||||||
|
)
|
||||||
|
)::INTEGER,
|
||||||
|
0
|
||||||
|
) AS "totalRateCents",
|
||||||
|
ROUND(
|
||||||
|
(
|
||||||
|
COALESCE(sr.pay_rate_cents, 0)
|
||||||
|
* GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0)
|
||||||
|
)::numeric / 100,
|
||||||
|
2
|
||||||
|
) AS "totalRate",
|
||||||
COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType",
|
COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType",
|
||||||
FALSE AS "instantBook",
|
FALSE AS "instantBook",
|
||||||
1::INTEGER AS "requiredWorkerCount"
|
1::INTEGER AS "requiredWorkerCount"
|
||||||
@@ -842,6 +968,7 @@ export async function listOpenShifts(actorUid, { limit, search } = {}) {
|
|||||||
JOIN shifts s ON s.id = a.shift_id
|
JOIN shifts s ON s.id = a.shift_id
|
||||||
JOIN shift_roles sr ON sr.id = a.shift_role_id
|
JOIN shift_roles sr ON sr.id = a.shift_role_id
|
||||||
JOIN orders o ON o.id = s.order_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 clock_points cp ON cp.id = s.clock_point_id
|
||||||
WHERE a.tenant_id = $1
|
WHERE a.tenant_id = $1
|
||||||
AND a.status = 'SWAP_REQUESTED'
|
AND a.status = 'SWAP_REQUESTED'
|
||||||
@@ -911,8 +1038,11 @@ export async function getStaffShiftDetail(actorUid, shiftId) {
|
|||||||
s.id AS "shiftId",
|
s.id AS "shiftId",
|
||||||
s.title,
|
s.title,
|
||||||
o.description,
|
o.description,
|
||||||
|
b.business_name AS "clientName",
|
||||||
COALESCE(cp.label, s.location_name) AS location,
|
COALESCE(cp.label, s.location_name) AS location,
|
||||||
s.location_address AS address,
|
COALESCE(s.location_address, cp.address) AS address,
|
||||||
|
COALESCE(s.latitude, cp.latitude) AS latitude,
|
||||||
|
COALESCE(s.longitude, cp.longitude) AS longitude,
|
||||||
s.starts_at AS date,
|
s.starts_at AS date,
|
||||||
s.starts_at AS "startTime",
|
s.starts_at AS "startTime",
|
||||||
s.ends_at AS "endTime",
|
s.ends_at AS "endTime",
|
||||||
@@ -923,6 +1053,23 @@ export async function getStaffShiftDetail(actorUid, shiftId) {
|
|||||||
sr.id AS "roleId",
|
sr.id AS "roleId",
|
||||||
sr.role_name AS "roleName",
|
sr.role_name AS "roleName",
|
||||||
sr.pay_rate_cents AS "hourlyRateCents",
|
sr.pay_rate_cents AS "hourlyRateCents",
|
||||||
|
ROUND(COALESCE(sr.pay_rate_cents, 0)::numeric / 100, 2) AS "hourlyRate",
|
||||||
|
COALESCE(
|
||||||
|
ROUND(
|
||||||
|
(
|
||||||
|
COALESCE(sr.pay_rate_cents, 0)
|
||||||
|
* GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0)
|
||||||
|
)
|
||||||
|
)::INTEGER,
|
||||||
|
0
|
||||||
|
) AS "totalRateCents",
|
||||||
|
ROUND(
|
||||||
|
(
|
||||||
|
COALESCE(sr.pay_rate_cents, 0)
|
||||||
|
* GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0)
|
||||||
|
)::numeric / 100,
|
||||||
|
2
|
||||||
|
) AS "totalRate",
|
||||||
COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType",
|
COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType",
|
||||||
sr.workers_needed AS "requiredCount",
|
sr.workers_needed AS "requiredCount",
|
||||||
sr.assigned_count AS "confirmedCount",
|
sr.assigned_count AS "confirmedCount",
|
||||||
@@ -930,6 +1077,7 @@ export async function getStaffShiftDetail(actorUid, shiftId) {
|
|||||||
app.status AS "applicationStatus"
|
app.status AS "applicationStatus"
|
||||||
FROM shifts s
|
FROM shifts s
|
||||||
JOIN orders o ON o.id = s.order_id
|
JOIN orders o ON o.id = s.order_id
|
||||||
|
JOIN businesses b ON b.id = s.business_id
|
||||||
JOIN shift_roles sr ON sr.shift_id = s.id
|
JOIN shift_roles sr ON sr.shift_id = s.id
|
||||||
LEFT JOIN clock_points cp ON cp.id = s.clock_point_id
|
LEFT JOIN clock_points cp ON cp.id = s.clock_point_id
|
||||||
LEFT JOIN assignments a ON a.shift_role_id = sr.id AND a.staff_id = $3
|
LEFT JOIN assignments a ON a.shift_role_id = sr.id AND a.staff_id = $3
|
||||||
@@ -981,12 +1129,41 @@ export async function listCompletedShifts(actorUid) {
|
|||||||
a.id AS "assignmentId",
|
a.id AS "assignmentId",
|
||||||
s.id AS "shiftId",
|
s.id AS "shiftId",
|
||||||
s.title,
|
s.title,
|
||||||
|
b.business_name AS "clientName",
|
||||||
COALESCE(cp.label, s.location_name) AS location,
|
COALESCE(cp.label, s.location_name) AS location,
|
||||||
s.starts_at AS date,
|
to_char(s.starts_at AT TIME ZONE 'UTC', 'YYYY-MM-DD') AS date,
|
||||||
|
s.starts_at AS "startTime",
|
||||||
|
s.ends_at AS "endTime",
|
||||||
|
COALESCE(sr.pay_rate_cents, 0)::INTEGER AS "hourlyRateCents",
|
||||||
|
ROUND(COALESCE(sr.pay_rate_cents, 0)::numeric / 100, 2) AS "hourlyRate",
|
||||||
|
COALESCE(ts.status, 'PENDING') AS "timesheetStatus",
|
||||||
COALESCE(ts.regular_minutes + ts.overtime_minutes, 0) AS "minutesWorked",
|
COALESCE(ts.regular_minutes + ts.overtime_minutes, 0) AS "minutesWorked",
|
||||||
|
COALESCE(
|
||||||
|
ts.gross_pay_cents,
|
||||||
|
ROUND(
|
||||||
|
(
|
||||||
|
COALESCE(sr.pay_rate_cents, 0)
|
||||||
|
* GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0)
|
||||||
|
)
|
||||||
|
)::BIGINT
|
||||||
|
) AS "totalRateCents",
|
||||||
|
ROUND(
|
||||||
|
COALESCE(
|
||||||
|
ts.gross_pay_cents,
|
||||||
|
ROUND(
|
||||||
|
(
|
||||||
|
COALESCE(sr.pay_rate_cents, 0)
|
||||||
|
* GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0)
|
||||||
|
)
|
||||||
|
)::BIGINT
|
||||||
|
)::numeric / 100,
|
||||||
|
2
|
||||||
|
) AS "totalRate",
|
||||||
COALESCE(rp.status, 'PENDING') AS "paymentStatus"
|
COALESCE(rp.status, 'PENDING') AS "paymentStatus"
|
||||||
FROM assignments a
|
FROM assignments a
|
||||||
JOIN shifts s ON s.id = a.shift_id
|
JOIN shifts s ON s.id = a.shift_id
|
||||||
|
JOIN businesses b ON b.id = s.business_id
|
||||||
|
LEFT JOIN shift_roles sr ON sr.id = a.shift_role_id
|
||||||
LEFT JOIN clock_points cp ON cp.id = s.clock_point_id
|
LEFT JOIN clock_points cp ON cp.id = s.clock_point_id
|
||||||
LEFT JOIN timesheets ts ON ts.assignment_id = a.id
|
LEFT JOIN timesheets ts ON ts.assignment_id = a.id
|
||||||
LEFT JOIN recent_payments rp ON rp.assignment_id = a.id
|
LEFT JOIN recent_payments rp ON rp.assignment_id = a.id
|
||||||
@@ -1003,19 +1180,22 @@ export async function listCompletedShifts(actorUid) {
|
|||||||
export async function getProfileSectionsStatus(actorUid) {
|
export async function getProfileSectionsStatus(actorUid) {
|
||||||
const context = await requireStaffContext(actorUid);
|
const context = await requireStaffContext(actorUid);
|
||||||
const completion = getProfileCompletionFromMetadata(context.staff);
|
const completion = getProfileCompletionFromMetadata(context.staff);
|
||||||
const [documents, certificates, benefits] = await Promise.all([
|
const [documents, certificates, benefits, attire, taxForms] = await Promise.all([
|
||||||
listProfileDocuments(actorUid),
|
listProfileDocuments(actorUid),
|
||||||
listCertificates(actorUid),
|
listCertificates(actorUid),
|
||||||
listStaffBenefits(actorUid),
|
listStaffBenefits(actorUid),
|
||||||
|
listAttireChecklist(actorUid),
|
||||||
|
listTaxForms(actorUid),
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
personalInfoCompleted: completion.fields.firstName && completion.fields.lastName && completion.fields.email && completion.fields.phone && completion.fields.preferredLocations,
|
personalInfoCompleted: completion.fields.firstName && completion.fields.lastName && completion.fields.email && completion.fields.phone && completion.fields.preferredLocations,
|
||||||
emergencyContactCompleted: completion.fields.emergencyContact,
|
emergencyContactCompleted: completion.fields.emergencyContact,
|
||||||
experienceCompleted: completion.fields.skills && completion.fields.industries,
|
experienceCompleted: completion.fields.skills && completion.fields.industries,
|
||||||
attireCompleted: documents.filter((item) => item.documentType === 'ATTIRE').every((item) => item.status === 'VERIFIED'),
|
attireCompleted: attire.every((item) => item.status === 'VERIFIED'),
|
||||||
taxFormsCompleted: documents.filter((item) => item.documentType === 'TAX_FORM').every((item) => item.status === 'VERIFIED'),
|
taxFormsCompleted: taxForms.every((item) => item.status === 'VERIFIED' || item.status === 'SUBMITTED'),
|
||||||
benefitsConfigured: benefits.length > 0,
|
benefitsConfigured: benefits.length > 0,
|
||||||
certificateCount: certificates.length,
|
certificateCount: certificates.length,
|
||||||
|
documentCount: documents.length,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1054,6 +1234,7 @@ export async function listProfileDocuments(actorUid) {
|
|||||||
d.id AS "documentId",
|
d.id AS "documentId",
|
||||||
d.document_type AS "documentType",
|
d.document_type AS "documentType",
|
||||||
d.name,
|
d.name,
|
||||||
|
COALESCE(d.metadata->>'description', '') AS description,
|
||||||
sd.id AS "staffDocumentId",
|
sd.id AS "staffDocumentId",
|
||||||
sd.file_uri AS "fileUri",
|
sd.file_uri AS "fileUri",
|
||||||
COALESCE(sd.status, 'NOT_UPLOADED') AS status,
|
COALESCE(sd.status, 'NOT_UPLOADED') AS status,
|
||||||
@@ -1065,7 +1246,7 @@ export async function listProfileDocuments(actorUid) {
|
|||||||
AND sd.tenant_id = d.tenant_id
|
AND sd.tenant_id = d.tenant_id
|
||||||
AND sd.staff_id = $2
|
AND sd.staff_id = $2
|
||||||
WHERE d.tenant_id = $1
|
WHERE d.tenant_id = $1
|
||||||
AND d.document_type IN ('DOCUMENT', 'GOVERNMENT_ID', 'ATTIRE', 'TAX_FORM')
|
AND d.document_type IN ('DOCUMENT', 'GOVERNMENT_ID')
|
||||||
ORDER BY d.name ASC
|
ORDER BY d.name ASC
|
||||||
`,
|
`,
|
||||||
[context.tenant.tenantId, context.staff.staffId]
|
[context.tenant.tenantId, context.staff.staffId]
|
||||||
@@ -1645,9 +1826,12 @@ export async function listTaxForms(actorUid) {
|
|||||||
SELECT
|
SELECT
|
||||||
d.id AS "documentId",
|
d.id AS "documentId",
|
||||||
d.name AS "formType",
|
d.name AS "formType",
|
||||||
|
COALESCE(d.metadata->>'description', '') AS description,
|
||||||
sd.id AS "staffDocumentId",
|
sd.id AS "staffDocumentId",
|
||||||
|
sd.file_uri AS "fileUri",
|
||||||
COALESCE(sd.metadata->>'formStatus', 'NOT_STARTED') AS status,
|
COALESCE(sd.metadata->>'formStatus', 'NOT_STARTED') AS status,
|
||||||
COALESCE(sd.metadata->'fields', '{}'::jsonb) AS fields
|
COALESCE(sd.metadata->'fields', '{}'::jsonb) AS fields,
|
||||||
|
sd.expires_at AS "expiresAt"
|
||||||
FROM documents d
|
FROM documents d
|
||||||
LEFT JOIN staff_documents sd
|
LEFT JOIN staff_documents sd
|
||||||
ON sd.document_id = d.id
|
ON sd.document_id = d.id
|
||||||
|
|||||||
@@ -87,6 +87,70 @@ async function uploadFile(path, token, {
|
|||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function finalizeVerifiedUpload({
|
||||||
|
token,
|
||||||
|
uploadCategory,
|
||||||
|
filename,
|
||||||
|
contentType,
|
||||||
|
content,
|
||||||
|
finalizePath,
|
||||||
|
finalizeMethod = 'PUT',
|
||||||
|
verificationType,
|
||||||
|
subjectId,
|
||||||
|
rules = {},
|
||||||
|
finalizeBody = {},
|
||||||
|
}) {
|
||||||
|
const uploaded = await uploadFile('/upload-file', token, {
|
||||||
|
filename,
|
||||||
|
contentType,
|
||||||
|
content,
|
||||||
|
fields: {
|
||||||
|
visibility: 'private',
|
||||||
|
category: uploadCategory,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const signed = await apiCall('/create-signed-url', {
|
||||||
|
method: 'POST',
|
||||||
|
token,
|
||||||
|
body: {
|
||||||
|
fileUri: uploaded.fileUri,
|
||||||
|
expiresInSeconds: 300,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const verification = await apiCall('/verifications', {
|
||||||
|
method: 'POST',
|
||||||
|
token,
|
||||||
|
body: {
|
||||||
|
type: verificationType,
|
||||||
|
subjectType: 'worker',
|
||||||
|
subjectId,
|
||||||
|
fileUri: uploaded.fileUri,
|
||||||
|
rules,
|
||||||
|
},
|
||||||
|
expectedStatus: 202,
|
||||||
|
});
|
||||||
|
|
||||||
|
const finalized = await apiCall(finalizePath, {
|
||||||
|
method: finalizeMethod,
|
||||||
|
token,
|
||||||
|
body: {
|
||||||
|
...finalizeBody,
|
||||||
|
verificationId: verification.verificationId,
|
||||||
|
fileUri: signed.signedUrl,
|
||||||
|
photoUrl: signed.signedUrl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
uploaded,
|
||||||
|
signed,
|
||||||
|
verification,
|
||||||
|
finalized,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function signInClient() {
|
async function signInClient() {
|
||||||
return apiCall('/auth/client/sign-in', {
|
return apiCall('/auth/client/sign-in', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -210,6 +274,10 @@ async function main() {
|
|||||||
token: ownerSession.sessionToken,
|
token: ownerSession.sessionToken,
|
||||||
});
|
});
|
||||||
assert.ok(Array.isArray(clientReorders.items));
|
assert.ok(Array.isArray(clientReorders.items));
|
||||||
|
if (clientReorders.items[0]) {
|
||||||
|
assert.equal(typeof clientReorders.items[0].hourlyRateCents, 'number');
|
||||||
|
assert.equal(typeof clientReorders.items[0].totalPriceCents, 'number');
|
||||||
|
}
|
||||||
logStep('client.reorders.ok', { count: clientReorders.items.length });
|
logStep('client.reorders.ok', { count: clientReorders.items.length });
|
||||||
|
|
||||||
const billingAccounts = await apiCall('/client/billing/accounts', {
|
const billingAccounts = await apiCall('/client/billing/accounts', {
|
||||||
@@ -317,6 +385,10 @@ async function main() {
|
|||||||
token: ownerSession.sessionToken,
|
token: ownerSession.sessionToken,
|
||||||
});
|
});
|
||||||
assert.ok(Array.isArray(viewedOrders.items));
|
assert.ok(Array.isArray(viewedOrders.items));
|
||||||
|
if (viewedOrders.items[0]) {
|
||||||
|
assert.ok(viewedOrders.items[0].clientName);
|
||||||
|
assert.equal(typeof viewedOrders.items[0].hourlyRate, 'number');
|
||||||
|
}
|
||||||
logStep('client.orders.view.ok', { count: viewedOrders.items.length });
|
logStep('client.orders.view.ok', { count: viewedOrders.items.length });
|
||||||
|
|
||||||
const reorderPreview = await apiCall(`/client/orders/${fixture.orders.completed.id}/reorder-preview`, {
|
const reorderPreview = await apiCall(`/client/orders/${fixture.orders.completed.id}/reorder-preview`, {
|
||||||
@@ -519,7 +591,7 @@ async function main() {
|
|||||||
assert.ok(createdPermanentOrder.orderId);
|
assert.ok(createdPermanentOrder.orderId);
|
||||||
logStep('client.orders.create-permanent.ok', createdPermanentOrder);
|
logStep('client.orders.create-permanent.ok', createdPermanentOrder);
|
||||||
|
|
||||||
const editedOrderCopy = await apiCall(`/client/orders/${fixture.orders.completed.id}/edit`, {
|
const editedOrderCopy = await apiCall(`/client/orders/${createdRecurringOrder.orderId}/edit`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
token: ownerSession.sessionToken,
|
token: ownerSession.sessionToken,
|
||||||
idempotencyKey: uniqueKey('order-edit'),
|
idempotencyKey: uniqueKey('order-edit'),
|
||||||
@@ -528,6 +600,7 @@ async function main() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
assert.ok(editedOrderCopy.orderId);
|
assert.ok(editedOrderCopy.orderId);
|
||||||
|
assert.notEqual(editedOrderCopy.orderId, createdRecurringOrder.orderId);
|
||||||
logStep('client.orders.edit-copy.ok', editedOrderCopy);
|
logStep('client.orders.edit-copy.ok', editedOrderCopy);
|
||||||
|
|
||||||
const cancelledOrder = await apiCall(`/client/orders/${createdOneTimeOrder.orderId}/cancel`, {
|
const cancelledOrder = await apiCall(`/client/orders/${createdOneTimeOrder.orderId}/cancel`, {
|
||||||
@@ -538,6 +611,7 @@ async function main() {
|
|||||||
reason: 'Smoke cancel validation',
|
reason: 'Smoke cancel validation',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
assert.equal(cancelledOrder.futureOnly, true);
|
||||||
logStep('client.orders.cancel.ok', cancelledOrder);
|
logStep('client.orders.cancel.ok', cancelledOrder);
|
||||||
|
|
||||||
const coverageReview = await apiCall('/client/coverage/reviews', {
|
const coverageReview = await apiCall('/client/coverage/reviews', {
|
||||||
@@ -609,6 +683,14 @@ async function main() {
|
|||||||
token: staffAuth.idToken,
|
token: staffAuth.idToken,
|
||||||
});
|
});
|
||||||
assert.ok(Array.isArray(staffDashboard.recommendedShifts));
|
assert.ok(Array.isArray(staffDashboard.recommendedShifts));
|
||||||
|
if (staffDashboard.todaysShifts[0]) {
|
||||||
|
assert.ok(staffDashboard.todaysShifts[0].clientName);
|
||||||
|
assert.equal(typeof staffDashboard.todaysShifts[0].totalRate, 'number');
|
||||||
|
}
|
||||||
|
if (staffDashboard.recommendedShifts[0]) {
|
||||||
|
assert.ok(staffDashboard.recommendedShifts[0].clientName);
|
||||||
|
assert.equal(typeof staffDashboard.recommendedShifts[0].totalRate, 'number');
|
||||||
|
}
|
||||||
logStep('staff.dashboard.ok', {
|
logStep('staff.dashboard.ok', {
|
||||||
todaysShifts: staffDashboard.todaysShifts.length,
|
todaysShifts: staffDashboard.todaysShifts.length,
|
||||||
recommendedShifts: staffDashboard.recommendedShifts.length,
|
recommendedShifts: staffDashboard.recommendedShifts.length,
|
||||||
@@ -693,12 +775,22 @@ async function main() {
|
|||||||
token: staffAuth.idToken,
|
token: staffAuth.idToken,
|
||||||
});
|
});
|
||||||
assert.ok(Array.isArray(completedShifts.items));
|
assert.ok(Array.isArray(completedShifts.items));
|
||||||
|
if (completedShifts.items[0]) {
|
||||||
|
assert.ok(completedShifts.items[0].clientName);
|
||||||
|
assert.ok(completedShifts.items[0].date);
|
||||||
|
assert.ok(completedShifts.items[0].startTime);
|
||||||
|
assert.ok(completedShifts.items[0].endTime);
|
||||||
|
assert.equal(typeof completedShifts.items[0].hourlyRate, 'number');
|
||||||
|
assert.equal(typeof completedShifts.items[0].totalRate, 'number');
|
||||||
|
}
|
||||||
logStep('staff.shifts.completed.ok', { count: completedShifts.items.length });
|
logStep('staff.shifts.completed.ok', { count: completedShifts.items.length });
|
||||||
|
|
||||||
const shiftDetail = await apiCall(`/staff/shifts/${openShift.shiftId}`, {
|
const shiftDetail = await apiCall(`/staff/shifts/${openShift.shiftId}`, {
|
||||||
token: staffAuth.idToken,
|
token: staffAuth.idToken,
|
||||||
});
|
});
|
||||||
assert.equal(shiftDetail.shiftId, openShift.shiftId);
|
assert.equal(shiftDetail.shiftId, openShift.shiftId);
|
||||||
|
assert.equal(typeof shiftDetail.latitude, 'number');
|
||||||
|
assert.equal(typeof shiftDetail.longitude, 'number');
|
||||||
logStep('staff.shifts.detail.ok', shiftDetail);
|
logStep('staff.shifts.detail.ok', shiftDetail);
|
||||||
|
|
||||||
const profileSections = await apiCall('/staff/profile/sections', {
|
const profileSections = await apiCall('/staff/profile/sections', {
|
||||||
@@ -727,6 +819,7 @@ async function main() {
|
|||||||
token: staffAuth.idToken,
|
token: staffAuth.idToken,
|
||||||
});
|
});
|
||||||
assert.ok(Array.isArray(profileDocumentsBefore.items));
|
assert.ok(Array.isArray(profileDocumentsBefore.items));
|
||||||
|
assert.ok(profileDocumentsBefore.items.every((item) => item.documentType !== 'ATTIRE'));
|
||||||
logStep('staff.profile.documents-before.ok', { count: profileDocumentsBefore.items.length });
|
logStep('staff.profile.documents-before.ok', { count: profileDocumentsBefore.items.length });
|
||||||
|
|
||||||
const attireChecklistBefore = await apiCall('/staff/profile/attire', {
|
const attireChecklistBefore = await apiCall('/staff/profile/attire', {
|
||||||
@@ -1054,6 +1147,17 @@ async function main() {
|
|||||||
assert.ok(clockOut.securityProofId);
|
assert.ok(clockOut.securityProofId);
|
||||||
logStep('staff.clock-out.ok', clockOut);
|
logStep('staff.clock-out.ok', clockOut);
|
||||||
|
|
||||||
|
const submittedCompletedShift = await apiCall(`/staff/shifts/${fixture.shifts.assigned.id}/submit-for-approval`, {
|
||||||
|
method: 'POST',
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
idempotencyKey: uniqueKey('staff-shift-submit-approval'),
|
||||||
|
body: {
|
||||||
|
note: 'Smoke approval submission',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(submittedCompletedShift.submitted, true);
|
||||||
|
logStep('staff.shifts.submit-for-approval.ok', submittedCompletedShift);
|
||||||
|
|
||||||
const requestedSwap = await apiCall(`/staff/shifts/${fixture.shifts.assigned.id}/request-swap`, {
|
const requestedSwap = await apiCall(`/staff/shifts/${fixture.shifts.assigned.id}/request-swap`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
token: staffAuth.idToken,
|
token: staffAuth.idToken,
|
||||||
@@ -1072,35 +1176,63 @@ async function main() {
|
|||||||
assert.ok(uploadedProfilePhoto.fileUri);
|
assert.ok(uploadedProfilePhoto.fileUri);
|
||||||
logStep('staff.profile.photo.upload.ok', uploadedProfilePhoto);
|
logStep('staff.profile.photo.upload.ok', uploadedProfilePhoto);
|
||||||
|
|
||||||
const uploadedGovId = await uploadFile(`/staff/profile/documents/${fixture.documents.governmentId.id}/upload`, staffAuth.idToken, {
|
const uploadedGovId = await finalizeVerifiedUpload({
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
uploadCategory: 'staff-document',
|
||||||
filename: 'government-id.jpg',
|
filename: 'government-id.jpg',
|
||||||
contentType: 'image/jpeg',
|
contentType: 'image/jpeg',
|
||||||
content: Buffer.from('fake-government-id'),
|
content: Buffer.from('fake-government-id'),
|
||||||
|
finalizePath: `/staff/profile/documents/${fixture.documents.governmentId.id}/upload`,
|
||||||
|
finalizeMethod: 'PUT',
|
||||||
|
verificationType: 'government_id',
|
||||||
|
subjectId: fixture.documents.governmentId.id,
|
||||||
|
rules: {
|
||||||
|
documentId: fixture.documents.governmentId.id,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
assert.equal(uploadedGovId.documentId, fixture.documents.governmentId.id);
|
assert.equal(uploadedGovId.finalized.documentId, fixture.documents.governmentId.id);
|
||||||
logStep('staff.profile.document.upload.ok', uploadedGovId);
|
logStep('staff.profile.document.upload.ok', uploadedGovId.finalized);
|
||||||
|
|
||||||
const uploadedAttire = await uploadFile(`/staff/profile/attire/${fixture.documents.attireBlackShirt.id}/upload`, staffAuth.idToken, {
|
const uploadedAttire = await finalizeVerifiedUpload({
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
uploadCategory: 'staff-attire',
|
||||||
filename: 'black-shirt.jpg',
|
filename: 'black-shirt.jpg',
|
||||||
contentType: 'image/jpeg',
|
contentType: 'image/jpeg',
|
||||||
content: Buffer.from('fake-black-shirt'),
|
content: Buffer.from('fake-black-shirt'),
|
||||||
|
finalizePath: `/staff/profile/attire/${fixture.documents.attireBlackShirt.id}/upload`,
|
||||||
|
finalizeMethod: 'PUT',
|
||||||
|
verificationType: 'attire',
|
||||||
|
subjectId: fixture.documents.attireBlackShirt.id,
|
||||||
|
rules: {
|
||||||
|
dressCode: 'Black shirt',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
assert.equal(uploadedAttire.documentId, fixture.documents.attireBlackShirt.id);
|
assert.equal(uploadedAttire.finalized.documentId, fixture.documents.attireBlackShirt.id);
|
||||||
logStep('staff.profile.attire.upload.ok', uploadedAttire);
|
logStep('staff.profile.attire.upload.ok', uploadedAttire.finalized);
|
||||||
|
|
||||||
const certificateType = `ALCOHOL_SERVICE_${Date.now()}`;
|
const certificateType = `ALCOHOL_SERVICE_${Date.now()}`;
|
||||||
const uploadedCertificate = await uploadFile('/staff/profile/certificates', staffAuth.idToken, {
|
const uploadedCertificate = await finalizeVerifiedUpload({
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
uploadCategory: 'staff-certificate',
|
||||||
filename: 'certificate.pdf',
|
filename: 'certificate.pdf',
|
||||||
contentType: 'application/pdf',
|
contentType: 'application/pdf',
|
||||||
content: Buffer.from('fake-certificate'),
|
content: Buffer.from('fake-certificate'),
|
||||||
fields: {
|
finalizePath: '/staff/profile/certificates',
|
||||||
|
finalizeMethod: 'POST',
|
||||||
|
verificationType: 'certification',
|
||||||
|
subjectId: certificateType,
|
||||||
|
rules: {
|
||||||
|
certificateName: 'Alcohol Service Permit',
|
||||||
|
certificateIssuer: 'Demo Issuer',
|
||||||
|
},
|
||||||
|
finalizeBody: {
|
||||||
certificateType,
|
certificateType,
|
||||||
name: 'Alcohol Service Permit',
|
name: 'Alcohol Service Permit',
|
||||||
issuer: 'Demo Issuer',
|
issuer: 'Demo Issuer',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
assert.equal(uploadedCertificate.certificateType, certificateType);
|
assert.equal(uploadedCertificate.finalized.certificateType, certificateType);
|
||||||
logStep('staff.profile.certificate.upload.ok', uploadedCertificate);
|
logStep('staff.profile.certificate.upload.ok', uploadedCertificate.finalized);
|
||||||
|
|
||||||
const profileDocumentsAfter = await apiCall('/staff/profile/documents', {
|
const profileDocumentsAfter = await apiCall('/staff/profile/documents', {
|
||||||
token: staffAuth.idToken,
|
token: staffAuth.idToken,
|
||||||
|
|||||||
@@ -20,14 +20,15 @@ const DIRECT_CORE_ALIASES = [
|
|||||||
{ methods: new Set(['POST']), pattern: /^\/invoke-llm$/, targetPath: (pathname) => `/core${pathname}` },
|
{ methods: new Set(['POST']), pattern: /^\/invoke-llm$/, targetPath: (pathname) => `/core${pathname}` },
|
||||||
{ methods: new Set(['POST']), pattern: /^\/rapid-orders\/transcribe$/, targetPath: (pathname) => `/core${pathname}` },
|
{ methods: new Set(['POST']), pattern: /^\/rapid-orders\/transcribe$/, targetPath: (pathname) => `/core${pathname}` },
|
||||||
{ methods: new Set(['POST']), pattern: /^\/rapid-orders\/parse$/, targetPath: (pathname) => `/core${pathname}` },
|
{ methods: new Set(['POST']), pattern: /^\/rapid-orders\/parse$/, targetPath: (pathname) => `/core${pathname}` },
|
||||||
|
{ methods: new Set(['POST']), pattern: /^\/rapid-orders\/process$/, targetPath: (pathname) => `/core${pathname}` },
|
||||||
{ methods: new Set(['POST']), pattern: /^\/staff\/profile\/photo$/, targetPath: (pathname) => `/core${pathname}` },
|
{ methods: new Set(['POST']), pattern: /^\/staff\/profile\/photo$/, targetPath: (pathname) => `/core${pathname}` },
|
||||||
{
|
{
|
||||||
methods: new Set(['POST']),
|
methods: new Set(['POST', 'PUT']),
|
||||||
pattern: /^\/staff\/profile\/documents\/([^/]+)\/upload$/,
|
pattern: /^\/staff\/profile\/documents\/([^/]+)\/upload$/,
|
||||||
targetPath: (_pathname, match) => `/core/staff/documents/${match[1]}/upload`,
|
targetPath: (_pathname, match) => `/core/staff/documents/${match[1]}/upload`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
methods: new Set(['POST']),
|
methods: new Set(['POST', 'PUT']),
|
||||||
pattern: /^\/staff\/profile\/attire\/([^/]+)\/upload$/,
|
pattern: /^\/staff\/profile\/attire\/([^/]+)\/upload$/,
|
||||||
targetPath: (_pathname, match) => `/core/staff/attire/${match[1]}/upload`,
|
targetPath: (_pathname, match) => `/core/staff/attire/${match[1]}/upload`,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -182,3 +182,56 @@ test('proxy forwards direct core upload aliases to core api', async () => {
|
|||||||
assert.equal(res.status, 200);
|
assert.equal(res.status, 200);
|
||||||
assert.equal(seenUrl, 'https://core.example/core/staff/certificates/upload');
|
assert.equal(seenUrl, 'https://core.example/core/staff/certificates/upload');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('proxy forwards PUT document upload aliases to core api', async () => {
|
||||||
|
process.env.QUERY_API_BASE_URL = 'https://query.example';
|
||||||
|
process.env.CORE_API_BASE_URL = 'https://core.example';
|
||||||
|
process.env.COMMAND_API_BASE_URL = 'https://command.example';
|
||||||
|
|
||||||
|
let seenUrl = null;
|
||||||
|
let seenMethod = null;
|
||||||
|
const app = createApp({
|
||||||
|
fetchImpl: async (url, init = {}) => {
|
||||||
|
seenUrl = `${url}`;
|
||||||
|
seenMethod = init.method;
|
||||||
|
return new Response(JSON.stringify({ ok: true }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put('/staff/profile/documents/doc-1/upload')
|
||||||
|
.set('Authorization', 'Bearer test-token')
|
||||||
|
.send({ verificationId: 'verification-1' });
|
||||||
|
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(seenMethod, 'PUT');
|
||||||
|
assert.equal(seenUrl, 'https://core.example/core/staff/documents/doc-1/upload');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('proxy forwards rapid order process alias to core api', async () => {
|
||||||
|
process.env.QUERY_API_BASE_URL = 'https://query.example';
|
||||||
|
process.env.CORE_API_BASE_URL = 'https://core.example';
|
||||||
|
process.env.COMMAND_API_BASE_URL = 'https://command.example';
|
||||||
|
|
||||||
|
let seenUrl = null;
|
||||||
|
const app = createApp({
|
||||||
|
fetchImpl: async (url) => {
|
||||||
|
seenUrl = `${url}`;
|
||||||
|
return new Response(JSON.stringify({ ok: true }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/rapid-orders/process')
|
||||||
|
.set('Authorization', 'Bearer test-token')
|
||||||
|
.send({ text: 'Need 2 servers ASAP for 4 hours' });
|
||||||
|
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(seenUrl, 'https://core.example/core/rapid-orders/process');
|
||||||
|
});
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ Those routes still exist for backend/internal compatibility, but mobile/frontend
|
|||||||
|
|
||||||
- [Authentication](./authentication.md)
|
- [Authentication](./authentication.md)
|
||||||
- [Unified API](./unified-api.md)
|
- [Unified API](./unified-api.md)
|
||||||
|
- [Staff Shifts](./staff-shifts.md)
|
||||||
- [Core API](./core-api.md)
|
- [Core API](./core-api.md)
|
||||||
- [Command API](./command-api.md)
|
- [Command API](./command-api.md)
|
||||||
- [Query API](./query-api.md)
|
- [Query API](./query-api.md)
|
||||||
|
|||||||
176
docs/BACKEND/API_GUIDES/V2/staff-shifts.md
Normal file
176
docs/BACKEND/API_GUIDES/V2/staff-shifts.md
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
# Staff Shifts V2
|
||||||
|
|
||||||
|
This document is the frontend handoff for the `staff/shifts/*` routes on the unified v2 API.
|
||||||
|
|
||||||
|
Base URL:
|
||||||
|
|
||||||
|
- `https://krow-api-v2-933560802882.us-central1.run.app`
|
||||||
|
|
||||||
|
## Read routes
|
||||||
|
|
||||||
|
- `GET /staff/shifts/assigned`
|
||||||
|
- `GET /staff/shifts/open`
|
||||||
|
- `GET /staff/shifts/pending`
|
||||||
|
- `GET /staff/shifts/cancelled`
|
||||||
|
- `GET /staff/shifts/completed`
|
||||||
|
- `GET /staff/shifts/:shiftId`
|
||||||
|
|
||||||
|
## Write routes
|
||||||
|
|
||||||
|
- `POST /staff/shifts/:shiftId/apply`
|
||||||
|
- `POST /staff/shifts/:shiftId/accept`
|
||||||
|
- `POST /staff/shifts/:shiftId/decline`
|
||||||
|
- `POST /staff/shifts/:shiftId/request-swap`
|
||||||
|
- `POST /staff/shifts/:shiftId/submit-for-approval`
|
||||||
|
|
||||||
|
All write routes require:
|
||||||
|
|
||||||
|
- `Authorization: Bearer <firebase-id-token>`
|
||||||
|
- `Idempotency-Key: <unique-per-action>`
|
||||||
|
|
||||||
|
## Shift lifecycle
|
||||||
|
|
||||||
|
### Find shifts
|
||||||
|
|
||||||
|
`GET /staff/shifts/open`
|
||||||
|
|
||||||
|
- use this for the worker marketplace feed
|
||||||
|
- the worker applies to a concrete shift role
|
||||||
|
- send the `roleId` returned by the open-shifts response
|
||||||
|
- `roleId` here means `shift_roles.id`, not the role catalog id
|
||||||
|
|
||||||
|
Apply request example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"roleId": "uuid",
|
||||||
|
"instantBook": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pending shifts
|
||||||
|
|
||||||
|
`GET /staff/shifts/pending`
|
||||||
|
|
||||||
|
- use `POST /staff/shifts/:shiftId/accept` to accept
|
||||||
|
- use `POST /staff/shifts/:shiftId/decline` to decline
|
||||||
|
|
||||||
|
### Assigned shifts
|
||||||
|
|
||||||
|
`GET /staff/shifts/assigned`
|
||||||
|
|
||||||
|
Each item now includes:
|
||||||
|
|
||||||
|
- `clientName`
|
||||||
|
- `hourlyRate`
|
||||||
|
- `totalRate`
|
||||||
|
- `startTime`
|
||||||
|
- `endTime`
|
||||||
|
|
||||||
|
### Shift detail
|
||||||
|
|
||||||
|
`GET /staff/shifts/:shiftId`
|
||||||
|
|
||||||
|
Each detail response now includes:
|
||||||
|
|
||||||
|
- `clientName`
|
||||||
|
- `latitude`
|
||||||
|
- `longitude`
|
||||||
|
- `hourlyRate`
|
||||||
|
- `totalRate`
|
||||||
|
|
||||||
|
Use this as the source of truth for the shift detail screen.
|
||||||
|
|
||||||
|
### Request swap
|
||||||
|
|
||||||
|
`POST /staff/shifts/:shiftId/request-swap`
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"reason": "Need coverage for a family emergency"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Current backend behavior:
|
||||||
|
|
||||||
|
- marks the assignment as `SWAP_REQUESTED`
|
||||||
|
- stores the reason
|
||||||
|
- emits `SHIFT_SWAP_REQUESTED`
|
||||||
|
- exposes the shift in the replacement pool
|
||||||
|
|
||||||
|
This is enough for the current staff UI.
|
||||||
|
It is not yet the full manager-side swap resolution lifecycle.
|
||||||
|
|
||||||
|
### Submit completed shift for approval
|
||||||
|
|
||||||
|
`POST /staff/shifts/:shiftId/submit-for-approval`
|
||||||
|
|
||||||
|
Use this after the worker has clocked out.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"note": "Worked full shift and all tasks were completed"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Current backend behavior:
|
||||||
|
|
||||||
|
- only allows shifts in `CHECKED_OUT` or `COMPLETED`
|
||||||
|
- creates or updates the assignment timesheet
|
||||||
|
- sets the timesheet to `SUBMITTED` unless it is already `APPROVED` or `PAID`
|
||||||
|
- emits `TIMESHEET_SUBMITTED_FOR_APPROVAL`
|
||||||
|
|
||||||
|
Example response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"assignmentId": "uuid",
|
||||||
|
"shiftId": "uuid",
|
||||||
|
"timesheetId": "uuid",
|
||||||
|
"status": "SUBMITTED",
|
||||||
|
"submitted": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Completed shifts
|
||||||
|
|
||||||
|
`GET /staff/shifts/completed`
|
||||||
|
|
||||||
|
Each item now includes:
|
||||||
|
|
||||||
|
- `date`
|
||||||
|
- `clientName`
|
||||||
|
- `startTime`
|
||||||
|
- `endTime`
|
||||||
|
- `hourlyRate`
|
||||||
|
- `totalRate`
|
||||||
|
- `timesheetStatus`
|
||||||
|
- `paymentStatus`
|
||||||
|
|
||||||
|
## Clock-in support fields
|
||||||
|
|
||||||
|
`GET /staff/clock-in/shifts/today`
|
||||||
|
|
||||||
|
Each item now includes:
|
||||||
|
|
||||||
|
- `clientName`
|
||||||
|
- `hourlyRate`
|
||||||
|
- `totalRate`
|
||||||
|
- `latitude`
|
||||||
|
- `longitude`
|
||||||
|
- `clockInMode`
|
||||||
|
- `allowClockInOverride`
|
||||||
|
|
||||||
|
## Frontend rule
|
||||||
|
|
||||||
|
Use the unified routes only.
|
||||||
|
|
||||||
|
Do not build new mobile work on:
|
||||||
|
|
||||||
|
- `/query/*`
|
||||||
|
- `/commands/*`
|
||||||
|
- `/core/*`
|
||||||
@@ -153,6 +153,7 @@ Example `GET /staff/clock-in/shifts/today` item:
|
|||||||
- `POST /staff/shifts/:shiftId/accept`
|
- `POST /staff/shifts/:shiftId/accept`
|
||||||
- `POST /staff/shifts/:shiftId/decline`
|
- `POST /staff/shifts/:shiftId/decline`
|
||||||
- `POST /staff/shifts/:shiftId/request-swap`
|
- `POST /staff/shifts/:shiftId/request-swap`
|
||||||
|
- `POST /staff/shifts/:shiftId/submit-for-approval`
|
||||||
- `PUT /staff/profile/personal-info`
|
- `PUT /staff/profile/personal-info`
|
||||||
- `PUT /staff/profile/experience`
|
- `PUT /staff/profile/experience`
|
||||||
- `PUT /staff/profile/locations`
|
- `PUT /staff/profile/locations`
|
||||||
@@ -174,6 +175,7 @@ These are exposed as direct unified aliases even though they are backed by `core
|
|||||||
- `POST /invoke-llm`
|
- `POST /invoke-llm`
|
||||||
- `POST /rapid-orders/transcribe`
|
- `POST /rapid-orders/transcribe`
|
||||||
- `POST /rapid-orders/parse`
|
- `POST /rapid-orders/parse`
|
||||||
|
- `POST /rapid-orders/process`
|
||||||
- `POST /verifications`
|
- `POST /verifications`
|
||||||
- `GET /verifications/:verificationId`
|
- `GET /verifications/:verificationId`
|
||||||
- `POST /verifications/:verificationId/review`
|
- `POST /verifications/:verificationId/review`
|
||||||
@@ -183,7 +185,9 @@ These are exposed as direct unified aliases even though they are backed by `core
|
|||||||
|
|
||||||
- `POST /staff/profile/photo`
|
- `POST /staff/profile/photo`
|
||||||
- `POST /staff/profile/documents/:documentId/upload`
|
- `POST /staff/profile/documents/:documentId/upload`
|
||||||
|
- `PUT /staff/profile/documents/:documentId/upload`
|
||||||
- `POST /staff/profile/attire/:documentId/upload`
|
- `POST /staff/profile/attire/:documentId/upload`
|
||||||
|
- `PUT /staff/profile/attire/:documentId/upload`
|
||||||
- `POST /staff/profile/certificates`
|
- `POST /staff/profile/certificates`
|
||||||
- `DELETE /staff/profile/certificates/:certificateId`
|
- `DELETE /staff/profile/certificates/:certificateId`
|
||||||
|
|
||||||
@@ -191,7 +195,21 @@ These are exposed as direct unified aliases even though they are backed by `core
|
|||||||
|
|
||||||
- `roleId` on `POST /staff/shifts/:shiftId/apply` is the concrete `shift_roles.id` for that shift, not the catalog role definition id.
|
- `roleId` on `POST /staff/shifts/:shiftId/apply` is the concrete `shift_roles.id` for that shift, not the catalog role definition id.
|
||||||
- `accountType` on `POST /staff/profile/bank-accounts` accepts either lowercase or uppercase and is normalized by the backend.
|
- `accountType` on `POST /staff/profile/bank-accounts` accepts either lowercase or uppercase and is normalized by the backend.
|
||||||
|
- Document routes now return only document rows. They do not mix in attire items anymore.
|
||||||
|
- Tax-form data should come from `GET /staff/profile/tax-forms`, not `GET /staff/profile/documents`.
|
||||||
- File upload routes return a storage path plus a signed URL. Frontend uploads the file directly to storage using that URL.
|
- File upload routes return a storage path plus a signed URL. Frontend uploads the file directly to storage using that URL.
|
||||||
|
- The frontend upload contract for documents, attire, and certificates is:
|
||||||
|
1. `POST /upload-file`
|
||||||
|
2. `POST /create-signed-url`
|
||||||
|
3. `POST /verifications`
|
||||||
|
4. finalize with:
|
||||||
|
- `PUT /staff/profile/documents/:documentId/upload`
|
||||||
|
- `PUT /staff/profile/attire/:documentId/upload`
|
||||||
|
- `POST /staff/profile/certificates`
|
||||||
|
- Finalization requires `verificationId`. Frontend may still send `fileUri` or `photoUrl`, but the backend treats the verification-linked file as the source of truth.
|
||||||
|
- `POST /rapid-orders/process` is the single-call route for "transcribe + parse".
|
||||||
|
- `POST /client/orders/:orderId/edit` builds a replacement order from future shifts only.
|
||||||
|
- `POST /client/orders/:orderId/cancel` cancels future shifts only on the mobile surface and leaves historical shifts intact.
|
||||||
- Verification upload and review routes are live and were validated through document, attire, and certificate flows. Do not rely on long-lived verification history durability until the dedicated persistence slice is landed in `core-api-v2`.
|
- Verification upload and review routes are live and were validated through document, attire, and certificate flows. Do not rely on long-lived verification history durability until the dedicated persistence slice is landed in `core-api-v2`.
|
||||||
- Attendance policy is explicit. Reads now expose `clockInMode` and `allowClockInOverride`.
|
- Attendance policy is explicit. Reads now expose `clockInMode` and `allowClockInOverride`.
|
||||||
- `clockInMode` values are:
|
- `clockInMode` values are:
|
||||||
|
|||||||
Reference in New Issue
Block a user