fix(api): close v2 mobile contract gaps
This commit is contained in:
@@ -196,6 +196,11 @@ export const shiftDecisionSchema = z.object({
|
||||
reason: z.string().max(1000).optional(),
|
||||
});
|
||||
|
||||
export const shiftSubmitApprovalSchema = z.object({
|
||||
shiftId: z.string().uuid(),
|
||||
note: z.string().max(2000).optional(),
|
||||
});
|
||||
|
||||
export const staffClockInSchema = z.object({
|
||||
assignmentId: z.string().uuid().optional(),
|
||||
shiftId: z.string().uuid().optional(),
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
setupStaffProfile,
|
||||
staffClockIn,
|
||||
staffClockOut,
|
||||
submitCompletedShiftForApproval,
|
||||
submitLocationStreamBatch,
|
||||
submitTaxForm,
|
||||
unregisterClientPushToken,
|
||||
@@ -70,6 +71,7 @@ import {
|
||||
pushTokenRegisterSchema,
|
||||
shiftApplySchema,
|
||||
shiftDecisionSchema,
|
||||
shiftSubmitApprovalSchema,
|
||||
staffClockInSchema,
|
||||
staffClockOutSchema,
|
||||
staffLocationBatchSchema,
|
||||
@@ -104,6 +106,7 @@ const defaultHandlers = {
|
||||
setupStaffProfile,
|
||||
staffClockIn,
|
||||
staffClockOut,
|
||||
submitCompletedShiftForApproval,
|
||||
submitLocationStreamBatch,
|
||||
submitTaxForm,
|
||||
unregisterClientPushToken,
|
||||
@@ -402,6 +405,14 @@ export function createMobileCommandsRouter(handlers = defaultHandlers) {
|
||||
paramShape: (req) => ({ ...req.body, shiftId: req.params.shiftId }),
|
||||
}));
|
||||
|
||||
router.post(...mobileCommand('/staff/shifts/:shiftId/submit-for-approval', {
|
||||
schema: shiftSubmitApprovalSchema,
|
||||
policyAction: 'staff.shifts.submit',
|
||||
resource: 'shift',
|
||||
handler: handlers.submitCompletedShiftForApproval,
|
||||
paramShape: (req) => ({ ...req.body, shiftId: req.params.shiftId }),
|
||||
}));
|
||||
|
||||
router.put(...mobileCommand('/staff/profile/personal-info', {
|
||||
schema: personalInfoUpdateSchema,
|
||||
policyAction: 'staff.profile.write',
|
||||
|
||||
@@ -8,12 +8,14 @@ import { uploadLocationBatch } from './location-log-storage.js';
|
||||
import { enqueueHubManagerAlert, enqueueUserAlert } from './notification-outbox.js';
|
||||
import { registerPushToken, unregisterPushToken } from './notification-device-tokens.js';
|
||||
import {
|
||||
cancelOrder as cancelOrderCommand,
|
||||
clockIn as clockInCommand,
|
||||
clockOut as clockOutCommand,
|
||||
createOrder as createOrderCommand,
|
||||
} from './command-service.js';
|
||||
|
||||
const MOBILE_CANCELLABLE_ASSIGNMENT_STATUSES = ['ASSIGNED', 'ACCEPTED'];
|
||||
const MOBILE_CANCELLABLE_APPLICATION_STATUSES = ['PENDING', 'CONFIRMED'];
|
||||
|
||||
function toIsoOrNull(value) {
|
||||
return value ? new Date(value).toISOString() : null;
|
||||
}
|
||||
@@ -397,18 +399,153 @@ async function loadEditableOrderTemplate(actorUid, tenantId, businessId, orderId
|
||||
WHERE o.id = $1
|
||||
AND o.tenant_id = $2
|
||||
AND o.business_id = $3
|
||||
AND s.starts_at > NOW()
|
||||
AND s.status NOT IN ('CANCELLED', 'COMPLETED')
|
||||
GROUP BY o.id
|
||||
`,
|
||||
[orderId, tenantId, businessId]
|
||||
);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
throw new AppError('NOT_FOUND', 'Order not found for edit flow', 404, { orderId });
|
||||
throw new AppError('ORDER_EDIT_BLOCKED', 'Order has no future shifts available for edit', 409, { orderId });
|
||||
}
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
async function cancelFutureOrderSlice(client, {
|
||||
actorUid,
|
||||
tenantId,
|
||||
businessId,
|
||||
orderId,
|
||||
reason,
|
||||
metadata = {},
|
||||
}) {
|
||||
const orderResult = await client.query(
|
||||
`
|
||||
SELECT id, order_number, status
|
||||
FROM orders
|
||||
WHERE id = $1
|
||||
AND tenant_id = $2
|
||||
AND business_id = $3
|
||||
FOR UPDATE
|
||||
`,
|
||||
[orderId, tenantId, businessId]
|
||||
);
|
||||
|
||||
if (orderResult.rowCount === 0) {
|
||||
throw new AppError('NOT_FOUND', 'Order not found for cancel flow', 404, { orderId });
|
||||
}
|
||||
|
||||
const order = orderResult.rows[0];
|
||||
const futureShiftsResult = await client.query(
|
||||
`
|
||||
SELECT id
|
||||
FROM shifts
|
||||
WHERE order_id = $1
|
||||
AND starts_at > NOW()
|
||||
AND status NOT IN ('CANCELLED', 'COMPLETED')
|
||||
ORDER BY starts_at ASC
|
||||
FOR UPDATE
|
||||
`,
|
||||
[order.id]
|
||||
);
|
||||
|
||||
if (futureShiftsResult.rowCount === 0) {
|
||||
return {
|
||||
orderId: order.id,
|
||||
orderNumber: order.order_number,
|
||||
status: order.status,
|
||||
futureOnly: true,
|
||||
cancelledShiftCount: 0,
|
||||
alreadyCancelled: true,
|
||||
};
|
||||
}
|
||||
|
||||
const shiftIds = futureShiftsResult.rows.map((row) => row.id);
|
||||
await client.query(
|
||||
`
|
||||
UPDATE orders
|
||||
SET metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`,
|
||||
[
|
||||
order.id,
|
||||
JSON.stringify({
|
||||
futureCancellationReason: reason || null,
|
||||
futureCancellationBy: actorUid,
|
||||
futureCancellationAt: new Date().toISOString(),
|
||||
...metadata,
|
||||
}),
|
||||
]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
UPDATE shifts
|
||||
SET status = 'CANCELLED',
|
||||
updated_at = NOW()
|
||||
WHERE id = ANY($1::uuid[])
|
||||
`,
|
||||
[shiftIds]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
UPDATE assignments
|
||||
SET status = 'CANCELLED',
|
||||
updated_at = NOW()
|
||||
WHERE shift_id = ANY($1::uuid[])
|
||||
AND status = ANY($2::text[])
|
||||
`,
|
||||
[shiftIds, MOBILE_CANCELLABLE_ASSIGNMENT_STATUSES]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
UPDATE applications
|
||||
SET status = 'CANCELLED',
|
||||
updated_at = NOW()
|
||||
WHERE shift_id = ANY($1::uuid[])
|
||||
AND status = ANY($2::text[])
|
||||
`,
|
||||
[shiftIds, MOBILE_CANCELLABLE_APPLICATION_STATUSES]
|
||||
);
|
||||
|
||||
for (const shiftId of shiftIds) {
|
||||
const roleIds = await client.query(
|
||||
'SELECT id FROM shift_roles WHERE shift_id = $1',
|
||||
[shiftId]
|
||||
);
|
||||
for (const role of roleIds.rows) {
|
||||
await refreshShiftRoleCounts(client, role.id);
|
||||
}
|
||||
await refreshShiftCounts(client, shiftId);
|
||||
}
|
||||
|
||||
await insertDomainEvent(client, {
|
||||
tenantId,
|
||||
aggregateType: 'order',
|
||||
aggregateId: order.id,
|
||||
eventType: 'ORDER_FUTURE_SLICE_CANCELLED',
|
||||
actorUserId: actorUid,
|
||||
payload: {
|
||||
reason: reason || null,
|
||||
shiftIds,
|
||||
futureOnly: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
orderId: order.id,
|
||||
orderNumber: order.order_number,
|
||||
status: 'CANCELLED',
|
||||
futureOnly: true,
|
||||
cancelledShiftCount: shiftIds.length,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveStaffAssignmentForClock(actorUid, tenantId, payload, { requireOpenSession = false } = {}) {
|
||||
const context = await requireStaffContext(actorUid);
|
||||
if (payload.assignmentId) {
|
||||
@@ -1547,11 +1684,31 @@ export async function createEditedOrderCopy(actor, payload) {
|
||||
);
|
||||
|
||||
const templateShifts = Array.isArray(template.shifts) ? template.shifts : [];
|
||||
const templatePositions = templateShifts.flatMap((shift) => (Array.isArray(shift.roles) ? shift.roles : []).map((role) => ({
|
||||
...role,
|
||||
startTime: role.startTime || shift.startTime,
|
||||
endTime: role.endTime || shift.endTime,
|
||||
})));
|
||||
const templatePositions = Array.from(
|
||||
templateShifts.reduce((deduped, shift) => {
|
||||
for (const role of (Array.isArray(shift.roles) ? shift.roles : [])) {
|
||||
const normalized = {
|
||||
...role,
|
||||
startTime: role.startTime || shift.startTime,
|
||||
endTime: role.endTime || shift.endTime,
|
||||
};
|
||||
const key = [
|
||||
normalized.roleId || '',
|
||||
normalized.roleCode || '',
|
||||
normalized.roleName || '',
|
||||
normalized.startTime || '',
|
||||
normalized.endTime || '',
|
||||
normalized.workerCount ?? '',
|
||||
normalized.payRateCents ?? '',
|
||||
normalized.billRateCents ?? '',
|
||||
].join('|');
|
||||
if (!deduped.has(key)) {
|
||||
deduped.set(key, normalized);
|
||||
}
|
||||
}
|
||||
return deduped;
|
||||
}, new Map()).values()
|
||||
);
|
||||
const firstShift = templateShifts[0] || {};
|
||||
const lastShift = templateShifts[templateShifts.length - 1] || {};
|
||||
const inferredOrderType = payload.orderType || template.metadata?.orderType || 'ONE_TIME';
|
||||
@@ -1593,11 +1750,16 @@ export async function createEditedOrderCopy(actor, payload) {
|
||||
|
||||
export async function cancelClientOrder(actor, payload) {
|
||||
const context = await requireClientContext(actor.uid);
|
||||
return cancelOrderCommand(actor, {
|
||||
tenantId: context.tenant.tenantId,
|
||||
orderId: payload.orderId,
|
||||
reason: payload.reason,
|
||||
metadata: payload.metadata,
|
||||
return withTransaction(async (client) => {
|
||||
await ensureActorUser(client, actor);
|
||||
return cancelFutureOrderSlice(client, {
|
||||
actorUid: actor.uid,
|
||||
tenantId: context.tenant.tenantId,
|
||||
businessId: context.business.businessId,
|
||||
orderId: payload.orderId,
|
||||
reason: payload.reason,
|
||||
metadata: payload.metadata,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2361,6 +2523,68 @@ export async function requestShiftSwap(actor, payload) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function submitCompletedShiftForApproval(actor, payload) {
|
||||
const context = await requireStaffContext(actor.uid);
|
||||
return withTransaction(async (client) => {
|
||||
await ensureActorUser(client, actor);
|
||||
const assignment = await requireAnyAssignmentForActor(client, context.tenant.tenantId, payload.shiftId, actor.uid);
|
||||
if (!['CHECKED_OUT', 'COMPLETED'].includes(assignment.status)) {
|
||||
throw new AppError('INVALID_TIMESHEET_STATE', 'Only completed or checked-out shifts can be submitted for approval', 409, {
|
||||
shiftId: payload.shiftId,
|
||||
assignmentStatus: assignment.status,
|
||||
});
|
||||
}
|
||||
|
||||
const timesheetResult = await client.query(
|
||||
`
|
||||
INSERT INTO timesheets (
|
||||
tenant_id,
|
||||
assignment_id,
|
||||
staff_id,
|
||||
status,
|
||||
metadata
|
||||
)
|
||||
VALUES ($1, $2, $3, 'SUBMITTED', $4::jsonb)
|
||||
ON CONFLICT (assignment_id) DO UPDATE
|
||||
SET status = CASE
|
||||
WHEN timesheets.status IN ('APPROVED', 'PAID') THEN timesheets.status
|
||||
ELSE 'SUBMITTED'
|
||||
END,
|
||||
metadata = COALESCE(timesheets.metadata, '{}'::jsonb) || EXCLUDED.metadata,
|
||||
updated_at = NOW()
|
||||
RETURNING id, status, metadata
|
||||
`,
|
||||
[
|
||||
context.tenant.tenantId,
|
||||
assignment.id,
|
||||
assignment.staff_id,
|
||||
JSON.stringify({
|
||||
submittedAt: new Date().toISOString(),
|
||||
submittedBy: actor.uid,
|
||||
submissionNote: payload.note || null,
|
||||
}),
|
||||
]
|
||||
);
|
||||
|
||||
await insertDomainEvent(client, {
|
||||
tenantId: context.tenant.tenantId,
|
||||
aggregateType: 'timesheet',
|
||||
aggregateId: timesheetResult.rows[0].id,
|
||||
eventType: 'TIMESHEET_SUBMITTED_FOR_APPROVAL',
|
||||
actorUserId: actor.uid,
|
||||
payload,
|
||||
});
|
||||
|
||||
return {
|
||||
assignmentId: assignment.id,
|
||||
shiftId: assignment.shift_id,
|
||||
timesheetId: timesheetResult.rows[0].id,
|
||||
status: timesheetResult.rows[0].status,
|
||||
submitted: timesheetResult.rows[0].status === 'SUBMITTED',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function setupStaffProfile(actor, payload) {
|
||||
return withTransaction(async (client) => {
|
||||
const scope = await resolveStaffOnboardingScope(client, actor.uid, payload.tenantId, payload.vendorId);
|
||||
|
||||
@@ -77,6 +77,12 @@ function createMobileHandlers() {
|
||||
assignmentId: payload.assignmentId || 'assignment-1',
|
||||
status: 'CLOCK_OUT',
|
||||
}),
|
||||
submitCompletedShiftForApproval: async (_actor, payload) => ({
|
||||
shiftId: payload.shiftId,
|
||||
timesheetId: 'timesheet-1',
|
||||
status: 'SUBMITTED',
|
||||
submitted: true,
|
||||
}),
|
||||
submitLocationStreamBatch: async (_actor, payload) => ({
|
||||
assignmentId: payload.assignmentId || 'assignment-1',
|
||||
pointCount: payload.points.length,
|
||||
@@ -342,3 +348,19 @@ test('POST /commands/staff/profile/bank-accounts uppercases account type', async
|
||||
assert.equal(res.body.accountType, 'CHECKING');
|
||||
assert.equal(res.body.last4, '7890');
|
||||
});
|
||||
|
||||
test('POST /commands/staff/shifts/:shiftId/submit-for-approval injects shift id from params', async () => {
|
||||
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
|
||||
const res = await request(app)
|
||||
.post('/commands/staff/shifts/77777777-7777-4777-8777-777777777777/submit-for-approval')
|
||||
.set('Authorization', 'Bearer test-token')
|
||||
.set('Idempotency-Key', 'shift-submit-approval-1')
|
||||
.send({
|
||||
note: 'Worked full shift and ready for approval',
|
||||
});
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.shiftId, '77777777-7777-4777-8777-777777777777');
|
||||
assert.equal(res.body.timesheetId, 'timesheet-1');
|
||||
assert.equal(res.body.submitted, true);
|
||||
});
|
||||
|
||||
@@ -10,6 +10,8 @@ import { rapidOrderParseSchema } from '../contracts/core/rapid-order-parse.js';
|
||||
import { rapidOrderTranscribeSchema } from '../contracts/core/rapid-order-transcribe.js';
|
||||
import { reviewVerificationSchema } from '../contracts/core/review-verification.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 { parseRapidOrderText, transcribeRapidOrderAudio } from '../services/rapid-order.js';
|
||||
import {
|
||||
@@ -26,6 +28,8 @@ import {
|
||||
} from '../services/verification-jobs.js';
|
||||
import {
|
||||
deleteCertificate,
|
||||
finalizeCertificateUpload,
|
||||
finalizeStaffDocumentUpload,
|
||||
uploadCertificate,
|
||||
uploadProfilePhoto,
|
||||
uploadStaffDocument,
|
||||
@@ -70,6 +74,35 @@ const certificateUploadMetaSchema = z.object({
|
||||
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) {
|
||||
const encoded = encodeURIComponent(fileUri);
|
||||
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) {
|
||||
try {
|
||||
const file = req.file;
|
||||
@@ -280,9 +379,74 @@ async function handleRapidOrderParse(req, res, next) {
|
||||
timezone: payload.timezone,
|
||||
now: payload.now,
|
||||
});
|
||||
const roleCatalog = await loadRapidOrderRoleCatalog(req.actor.uid);
|
||||
|
||||
return res.status(200).json({
|
||||
...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,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
@@ -341,14 +505,25 @@ async function handleProfilePhotoUpload(req, res, next) {
|
||||
async function handleDocumentUpload(req, res, next) {
|
||||
try {
|
||||
const file = req.file;
|
||||
if (!file) {
|
||||
throw new AppError('INVALID_FILE', 'Missing file in multipart form data', 400);
|
||||
if (file) {
|
||||
const result = await uploadStaffDocument({
|
||||
actorUid: req.actor.uid,
|
||||
documentId: req.params.documentId,
|
||||
file,
|
||||
routeType: 'document',
|
||||
});
|
||||
return res.status(200).json({
|
||||
...result,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
}
|
||||
const result = await uploadStaffDocument({
|
||||
|
||||
const payload = parseBody(finalizedDocumentUploadSchema, req.body || {});
|
||||
const result = await finalizeStaffDocumentUpload({
|
||||
actorUid: req.actor.uid,
|
||||
documentId: req.params.documentId,
|
||||
file,
|
||||
routeType: 'document',
|
||||
verificationId: payload.verificationId,
|
||||
});
|
||||
return res.status(200).json({
|
||||
...result,
|
||||
@@ -362,14 +537,25 @@ async function handleDocumentUpload(req, res, next) {
|
||||
async function handleAttireUpload(req, res, next) {
|
||||
try {
|
||||
const file = req.file;
|
||||
if (!file) {
|
||||
throw new AppError('INVALID_FILE', 'Missing file in multipart form data', 400);
|
||||
if (file) {
|
||||
const result = await uploadStaffDocument({
|
||||
actorUid: req.actor.uid,
|
||||
documentId: req.params.documentId,
|
||||
file,
|
||||
routeType: 'attire',
|
||||
});
|
||||
return res.status(200).json({
|
||||
...result,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
}
|
||||
const result = await uploadStaffDocument({
|
||||
|
||||
const payload = parseBody(finalizedDocumentUploadSchema, req.body || {});
|
||||
const result = await finalizeStaffDocumentUpload({
|
||||
actorUid: req.actor.uid,
|
||||
documentId: req.params.documentId,
|
||||
file,
|
||||
routeType: 'attire',
|
||||
verificationId: payload.verificationId,
|
||||
});
|
||||
return res.status(200).json({
|
||||
...result,
|
||||
@@ -383,13 +569,22 @@ async function handleAttireUpload(req, res, next) {
|
||||
async function handleCertificateUpload(req, res, next) {
|
||||
try {
|
||||
const file = req.file;
|
||||
if (!file) {
|
||||
throw new AppError('INVALID_FILE', 'Missing file in multipart form data', 400);
|
||||
if (file) {
|
||||
const payload = parseBody(certificateUploadMetaSchema, req.body || {});
|
||||
const result = await uploadCertificate({
|
||||
actorUid: req.actor.uid,
|
||||
file,
|
||||
payload,
|
||||
});
|
||||
return res.status(200).json({
|
||||
...result,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
}
|
||||
const payload = parseBody(certificateUploadMetaSchema, req.body || {});
|
||||
const result = await uploadCertificate({
|
||||
|
||||
const payload = parseBody(finalizedCertificateUploadSchema, req.body || {});
|
||||
const result = await finalizeCertificateUpload({
|
||||
actorUid: req.actor.uid,
|
||||
file,
|
||||
payload,
|
||||
});
|
||||
return res.status(200).json({
|
||||
@@ -464,9 +659,12 @@ export function createCoreRouter() {
|
||||
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/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/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.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.delete('/staff/certificates/:certificateType', requireAuth, requirePolicy('core.upload', 'file'), handleCertificateDelete);
|
||||
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 { generateReadSignedUrl, uploadToGcs } from './storage.js';
|
||||
import { query, withTransaction } from './db.js';
|
||||
import { createVerificationJob } from './verification-jobs.js';
|
||||
import { createVerificationJob, getVerificationJob } from './verification-jobs.js';
|
||||
|
||||
function safeName(value) {
|
||||
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 }) {
|
||||
const context = await requireStaffContext(actorUid);
|
||||
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 }) {
|
||||
const context = await requireStaffContext(actorUid);
|
||||
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 }) {
|
||||
const context = await requireStaffContext(actorUid);
|
||||
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');
|
||||
});
|
||||
|
||||
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 () => {
|
||||
process.env.LLM_RATE_LIMIT_PER_MINUTE = '1';
|
||||
const app = createApp();
|
||||
|
||||
@@ -171,17 +171,32 @@ export async function listRecentReorders(actorUid, limit) {
|
||||
o.id,
|
||||
o.title,
|
||||
o.starts_at AS "date",
|
||||
COALESCE(cp.label, o.location_name) AS "hubName",
|
||||
COALESCE(COUNT(sr.id), 0)::INTEGER AS "positionCount",
|
||||
COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType"
|
||||
MAX(COALESCE(cp.label, o.location_name)) AS "hubName",
|
||||
MAX(b.business_name) AS "clientName",
|
||||
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
|
||||
JOIN businesses b ON b.id = o.business_id
|
||||
LEFT JOIN shifts s ON s.order_id = o.id
|
||||
LEFT JOIN shift_roles sr ON sr.shift_id = s.id
|
||||
LEFT JOIN clock_points cp ON cp.id = s.clock_point_id
|
||||
WHERE o.tenant_id = $1
|
||||
AND o.business_id = $2
|
||||
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
|
||||
LIMIT $3
|
||||
`,
|
||||
@@ -520,15 +535,33 @@ export async function listOrderItemsByDateRange(actorUid, { startDate, endDate }
|
||||
sr.id AS "itemId",
|
||||
o.id AS "orderId",
|
||||
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",
|
||||
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.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.assigned_count AS "filledCount",
|
||||
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",
|
||||
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(s.location_address, cp.address) AS "locationAddress",
|
||||
hm.business_membership_id AS "hubManagerId",
|
||||
COALESCE(u.display_name, u.email) AS "hubManagerName",
|
||||
s.status,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
@@ -544,14 +577,34 @@ export async function listOrderItemsByDateRange(actorUid, { startDate, endDate }
|
||||
FROM shift_roles sr
|
||||
JOIN shifts s ON s.id = sr.shift_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 assignments a ON a.shift_role_id = sr.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
|
||||
AND o.business_id = $2
|
||||
AND s.starts_at >= $3::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
|
||||
`,
|
||||
[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,
|
||||
b.business_name AS "clientName",
|
||||
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",
|
||||
COALESCE(cp.label, s.location_name) AS location,
|
||||
COALESCE(s.location_address, cp.address) AS "locationAddress",
|
||||
@@ -656,7 +726,7 @@ export async function listTodayShifts(actorUid) {
|
||||
AND a.staff_id = $2
|
||||
AND s.starts_at >= $3::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
|
||||
`,
|
||||
[context.tenant.tenantId, context.staff.staffId, from, to]
|
||||
@@ -767,24 +837,43 @@ export async function listAssignedShifts(actorUid, { startDate, endDate }) {
|
||||
SELECT
|
||||
a.id AS "assignmentId",
|
||||
s.id AS "shiftId",
|
||||
b.business_name AS "clientName",
|
||||
sr.role_name AS "roleName",
|
||||
COALESCE(cp.label, s.location_name) AS location,
|
||||
s.starts_at AS date,
|
||||
s.starts_at AS "startTime",
|
||||
s.ends_at AS "endTime",
|
||||
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",
|
||||
a.status
|
||||
FROM assignments a
|
||||
JOIN shifts s ON s.id = a.shift_id
|
||||
JOIN shift_roles sr ON sr.id = a.shift_role_id
|
||||
JOIN orders o ON o.id = s.order_id
|
||||
JOIN businesses b ON b.id = s.business_id
|
||||
LEFT JOIN clock_points cp ON cp.id = s.clock_point_id
|
||||
WHERE a.tenant_id = $1
|
||||
AND a.staff_id = $2
|
||||
AND s.starts_at >= $3::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
|
||||
`,
|
||||
[context.tenant.tenantId, context.staff.staffId, range.start, range.end]
|
||||
@@ -800,18 +889,37 @@ export async function listOpenShifts(actorUid, { limit, search } = {}) {
|
||||
SELECT
|
||||
s.id AS "shiftId",
|
||||
sr.id AS "roleId",
|
||||
b.business_name AS "clientName",
|
||||
sr.role_name AS "roleName",
|
||||
COALESCE(cp.label, s.location_name) AS location,
|
||||
s.starts_at AS date,
|
||||
s.starts_at AS "startTime",
|
||||
s.ends_at AS "endTime",
|
||||
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",
|
||||
FALSE AS "instantBook",
|
||||
sr.workers_needed AS "requiredWorkerCount"
|
||||
FROM shifts s
|
||||
JOIN shift_roles sr ON sr.shift_id = s.id
|
||||
JOIN orders o ON o.id = s.order_id
|
||||
JOIN businesses b ON b.id = s.business_id
|
||||
LEFT JOIN clock_points cp ON cp.id = s.clock_point_id
|
||||
WHERE s.tenant_id = $1
|
||||
AND s.status = 'OPEN'
|
||||
@@ -829,12 +937,30 @@ export async function listOpenShifts(actorUid, { limit, search } = {}) {
|
||||
SELECT
|
||||
s.id AS "shiftId",
|
||||
sr.id AS "roleId",
|
||||
b.business_name AS "clientName",
|
||||
sr.role_name AS "roleName",
|
||||
COALESCE(cp.label, s.location_name) AS location,
|
||||
s.starts_at AS date,
|
||||
s.starts_at AS "startTime",
|
||||
s.ends_at AS "endTime",
|
||||
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",
|
||||
FALSE AS "instantBook",
|
||||
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 shift_roles sr ON sr.id = a.shift_role_id
|
||||
JOIN orders o ON o.id = s.order_id
|
||||
JOIN businesses b ON b.id = s.business_id
|
||||
LEFT JOIN clock_points cp ON cp.id = s.clock_point_id
|
||||
WHERE a.tenant_id = $1
|
||||
AND a.status = 'SWAP_REQUESTED'
|
||||
@@ -911,8 +1038,11 @@ export async function getStaffShiftDetail(actorUid, shiftId) {
|
||||
s.id AS "shiftId",
|
||||
s.title,
|
||||
o.description,
|
||||
b.business_name AS "clientName",
|
||||
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 "startTime",
|
||||
s.ends_at AS "endTime",
|
||||
@@ -923,6 +1053,23 @@ export async function getStaffShiftDetail(actorUid, shiftId) {
|
||||
sr.id AS "roleId",
|
||||
sr.role_name AS "roleName",
|
||||
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",
|
||||
sr.workers_needed AS "requiredCount",
|
||||
sr.assigned_count AS "confirmedCount",
|
||||
@@ -930,6 +1077,7 @@ export async function getStaffShiftDetail(actorUid, shiftId) {
|
||||
app.status AS "applicationStatus"
|
||||
FROM shifts s
|
||||
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
|
||||
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
|
||||
@@ -981,12 +1129,41 @@ export async function listCompletedShifts(actorUid) {
|
||||
a.id AS "assignmentId",
|
||||
s.id AS "shiftId",
|
||||
s.title,
|
||||
b.business_name AS "clientName",
|
||||
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.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"
|
||||
FROM assignments a
|
||||
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 timesheets ts ON ts.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) {
|
||||
const context = await requireStaffContext(actorUid);
|
||||
const completion = getProfileCompletionFromMetadata(context.staff);
|
||||
const [documents, certificates, benefits] = await Promise.all([
|
||||
const [documents, certificates, benefits, attire, taxForms] = await Promise.all([
|
||||
listProfileDocuments(actorUid),
|
||||
listCertificates(actorUid),
|
||||
listStaffBenefits(actorUid),
|
||||
listAttireChecklist(actorUid),
|
||||
listTaxForms(actorUid),
|
||||
]);
|
||||
return {
|
||||
personalInfoCompleted: completion.fields.firstName && completion.fields.lastName && completion.fields.email && completion.fields.phone && completion.fields.preferredLocations,
|
||||
emergencyContactCompleted: completion.fields.emergencyContact,
|
||||
experienceCompleted: completion.fields.skills && completion.fields.industries,
|
||||
attireCompleted: documents.filter((item) => item.documentType === 'ATTIRE').every((item) => item.status === 'VERIFIED'),
|
||||
taxFormsCompleted: documents.filter((item) => item.documentType === 'TAX_FORM').every((item) => item.status === 'VERIFIED'),
|
||||
attireCompleted: attire.every((item) => item.status === 'VERIFIED'),
|
||||
taxFormsCompleted: taxForms.every((item) => item.status === 'VERIFIED' || item.status === 'SUBMITTED'),
|
||||
benefitsConfigured: benefits.length > 0,
|
||||
certificateCount: certificates.length,
|
||||
documentCount: documents.length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1054,6 +1234,7 @@ export async function listProfileDocuments(actorUid) {
|
||||
d.id AS "documentId",
|
||||
d.document_type AS "documentType",
|
||||
d.name,
|
||||
COALESCE(d.metadata->>'description', '') AS description,
|
||||
sd.id AS "staffDocumentId",
|
||||
sd.file_uri AS "fileUri",
|
||||
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.staff_id = $2
|
||||
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
|
||||
`,
|
||||
[context.tenant.tenantId, context.staff.staffId]
|
||||
@@ -1645,9 +1826,12 @@ export async function listTaxForms(actorUid) {
|
||||
SELECT
|
||||
d.id AS "documentId",
|
||||
d.name AS "formType",
|
||||
COALESCE(d.metadata->>'description', '') AS description,
|
||||
sd.id AS "staffDocumentId",
|
||||
sd.file_uri AS "fileUri",
|
||||
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
|
||||
LEFT JOIN staff_documents sd
|
||||
ON sd.document_id = d.id
|
||||
|
||||
@@ -87,6 +87,70 @@ async function uploadFile(path, token, {
|
||||
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() {
|
||||
return apiCall('/auth/client/sign-in', {
|
||||
method: 'POST',
|
||||
@@ -210,6 +274,10 @@ async function main() {
|
||||
token: ownerSession.sessionToken,
|
||||
});
|
||||
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 });
|
||||
|
||||
const billingAccounts = await apiCall('/client/billing/accounts', {
|
||||
@@ -317,6 +385,10 @@ async function main() {
|
||||
token: ownerSession.sessionToken,
|
||||
});
|
||||
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 });
|
||||
|
||||
const reorderPreview = await apiCall(`/client/orders/${fixture.orders.completed.id}/reorder-preview`, {
|
||||
@@ -519,7 +591,7 @@ async function main() {
|
||||
assert.ok(createdPermanentOrder.orderId);
|
||||
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',
|
||||
token: ownerSession.sessionToken,
|
||||
idempotencyKey: uniqueKey('order-edit'),
|
||||
@@ -528,6 +600,7 @@ async function main() {
|
||||
},
|
||||
});
|
||||
assert.ok(editedOrderCopy.orderId);
|
||||
assert.notEqual(editedOrderCopy.orderId, createdRecurringOrder.orderId);
|
||||
logStep('client.orders.edit-copy.ok', editedOrderCopy);
|
||||
|
||||
const cancelledOrder = await apiCall(`/client/orders/${createdOneTimeOrder.orderId}/cancel`, {
|
||||
@@ -538,6 +611,7 @@ async function main() {
|
||||
reason: 'Smoke cancel validation',
|
||||
},
|
||||
});
|
||||
assert.equal(cancelledOrder.futureOnly, true);
|
||||
logStep('client.orders.cancel.ok', cancelledOrder);
|
||||
|
||||
const coverageReview = await apiCall('/client/coverage/reviews', {
|
||||
@@ -609,6 +683,14 @@ async function main() {
|
||||
token: staffAuth.idToken,
|
||||
});
|
||||
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', {
|
||||
todaysShifts: staffDashboard.todaysShifts.length,
|
||||
recommendedShifts: staffDashboard.recommendedShifts.length,
|
||||
@@ -693,12 +775,22 @@ async function main() {
|
||||
token: staffAuth.idToken,
|
||||
});
|
||||
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 });
|
||||
|
||||
const shiftDetail = await apiCall(`/staff/shifts/${openShift.shiftId}`, {
|
||||
token: staffAuth.idToken,
|
||||
});
|
||||
assert.equal(shiftDetail.shiftId, openShift.shiftId);
|
||||
assert.equal(typeof shiftDetail.latitude, 'number');
|
||||
assert.equal(typeof shiftDetail.longitude, 'number');
|
||||
logStep('staff.shifts.detail.ok', shiftDetail);
|
||||
|
||||
const profileSections = await apiCall('/staff/profile/sections', {
|
||||
@@ -727,6 +819,7 @@ async function main() {
|
||||
token: staffAuth.idToken,
|
||||
});
|
||||
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 });
|
||||
|
||||
const attireChecklistBefore = await apiCall('/staff/profile/attire', {
|
||||
@@ -1054,6 +1147,17 @@ async function main() {
|
||||
assert.ok(clockOut.securityProofId);
|
||||
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`, {
|
||||
method: 'POST',
|
||||
token: staffAuth.idToken,
|
||||
@@ -1072,35 +1176,63 @@ async function main() {
|
||||
assert.ok(uploadedProfilePhoto.fileUri);
|
||||
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',
|
||||
contentType: 'image/jpeg',
|
||||
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);
|
||||
logStep('staff.profile.document.upload.ok', uploadedGovId);
|
||||
assert.equal(uploadedGovId.finalized.documentId, fixture.documents.governmentId.id);
|
||||
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',
|
||||
contentType: 'image/jpeg',
|
||||
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);
|
||||
logStep('staff.profile.attire.upload.ok', uploadedAttire);
|
||||
assert.equal(uploadedAttire.finalized.documentId, fixture.documents.attireBlackShirt.id);
|
||||
logStep('staff.profile.attire.upload.ok', uploadedAttire.finalized);
|
||||
|
||||
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',
|
||||
contentType: 'application/pdf',
|
||||
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,
|
||||
name: 'Alcohol Service Permit',
|
||||
issuer: 'Demo Issuer',
|
||||
},
|
||||
});
|
||||
assert.equal(uploadedCertificate.certificateType, certificateType);
|
||||
logStep('staff.profile.certificate.upload.ok', uploadedCertificate);
|
||||
assert.equal(uploadedCertificate.finalized.certificateType, certificateType);
|
||||
logStep('staff.profile.certificate.upload.ok', uploadedCertificate.finalized);
|
||||
|
||||
const profileDocumentsAfter = await apiCall('/staff/profile/documents', {
|
||||
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: /^\/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\/process$/, 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$/,
|
||||
targetPath: (_pathname, match) => `/core/staff/documents/${match[1]}/upload`,
|
||||
},
|
||||
{
|
||||
methods: new Set(['POST']),
|
||||
methods: new Set(['POST', 'PUT']),
|
||||
pattern: /^\/staff\/profile\/attire\/([^/]+)\/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(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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user