feat(api): complete unified v2 mobile surface

This commit is contained in:
zouantchaw
2026-03-13 17:02:24 +01:00
parent 817a39e305
commit b455455a49
39 changed files with 7726 additions and 506 deletions

View File

@@ -5,6 +5,7 @@ import { requestContext } from './middleware/request-context.js';
import { errorHandler, notFoundHandler } from './middleware/error-handler.js';
import { healthRouter } from './routes/health.js';
import { createCommandsRouter } from './routes/commands.js';
import { createMobileCommandsRouter } from './routes/mobile.js';
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
@@ -22,6 +23,7 @@ export function createApp(options = {}) {
app.use(healthRouter);
app.use('/commands', createCommandsRouter(options.commandHandlers));
app.use('/commands', createMobileCommandsRouter(options.mobileCommandHandlers));
app.use(notFoundHandler);
app.use(errorHandler);

View File

@@ -0,0 +1,301 @@
import { z } from 'zod';
const timeSlotSchema = z.object({
start: z.string().min(1).max(20),
end: z.string().min(1).max(20),
});
const preferredLocationSchema = z.object({
label: z.string().min(1).max(160),
city: z.string().max(120).optional(),
state: z.string().max(80).optional(),
latitude: z.number().min(-90).max(90).optional(),
longitude: z.number().min(-180).max(180).optional(),
radiusMiles: z.number().nonnegative().optional(),
});
const hhmmSchema = z.string().regex(/^\d{2}:\d{2}$/, 'Time must use HH:MM format');
const isoDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must use YYYY-MM-DD format');
const shiftPositionSchema = z.object({
roleId: z.string().uuid().optional(),
roleCode: z.string().min(1).max(120).optional(),
roleName: z.string().min(1).max(160).optional(),
workerCount: z.number().int().positive().optional(),
workersNeeded: z.number().int().positive().optional(),
startTime: hhmmSchema,
endTime: hhmmSchema,
hourlyRateCents: z.number().int().nonnegative().optional(),
payRateCents: z.number().int().nonnegative().optional(),
billRateCents: z.number().int().nonnegative().optional(),
lunchBreakMinutes: z.number().int().nonnegative().optional(),
paidBreak: z.boolean().optional(),
instantBook: z.boolean().optional(),
metadata: z.record(z.any()).optional(),
}).superRefine((value, ctx) => {
if (!value.roleId && !value.roleCode && !value.roleName) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'roleId, roleCode, or roleName is required',
path: ['roleId'],
});
}
});
const baseOrderCreateSchema = z.object({
hubId: z.string().uuid(),
vendorId: z.string().uuid().optional(),
eventName: z.string().min(2).max(160),
timezone: z.string().min(1).max(80).optional(),
description: z.string().max(5000).optional(),
notes: z.string().max(5000).optional(),
serviceType: z.enum(['EVENT', 'CATERING', 'HOTEL', 'RESTAURANT', 'OTHER']).optional(),
positions: z.array(shiftPositionSchema).min(1),
metadata: z.record(z.any()).optional(),
});
export const hubCreateSchema = z.object({
name: z.string().min(1).max(160),
fullAddress: z.string().max(300).optional(),
placeId: z.string().max(255).optional(),
latitude: z.number().min(-90).max(90).optional(),
longitude: z.number().min(-180).max(180).optional(),
street: z.string().max(160).optional(),
city: z.string().max(120).optional(),
state: z.string().max(80).optional(),
country: z.string().max(80).optional(),
zipCode: z.string().max(40).optional(),
costCenterId: z.string().uuid().optional(),
geofenceRadiusMeters: z.number().int().positive().optional(),
nfcTagId: z.string().max(255).optional(),
});
export const hubUpdateSchema = hubCreateSchema.extend({
hubId: z.string().uuid(),
});
export const hubDeleteSchema = z.object({
hubId: z.string().uuid(),
reason: z.string().max(1000).optional(),
});
export const hubAssignNfcSchema = z.object({
hubId: z.string().uuid(),
nfcTagId: z.string().min(1).max(255),
});
export const hubAssignManagerSchema = z.object({
hubId: z.string().uuid(),
businessMembershipId: z.string().uuid().optional(),
managerUserId: z.string().min(1).optional(),
}).refine((value) => value.businessMembershipId || value.managerUserId, {
message: 'businessMembershipId or managerUserId is required',
});
export const invoiceApproveSchema = z.object({
invoiceId: z.string().uuid(),
});
export const invoiceDisputeSchema = z.object({
invoiceId: z.string().uuid(),
reason: z.string().min(3).max(2000),
});
export const coverageReviewSchema = z.object({
staffId: z.string().uuid(),
assignmentId: z.string().uuid().optional(),
rating: z.number().int().min(1).max(5),
markAsFavorite: z.boolean().optional(),
issueFlags: z.array(z.string().min(1).max(80)).max(20).optional(),
feedback: z.string().max(5000).optional(),
});
export const cancelLateWorkerSchema = z.object({
assignmentId: z.string().uuid(),
reason: z.string().max(1000).optional(),
});
export const clientOneTimeOrderSchema = baseOrderCreateSchema.extend({
orderDate: isoDateSchema,
});
export const clientRecurringOrderSchema = baseOrderCreateSchema.extend({
startDate: isoDateSchema,
endDate: isoDateSchema,
recurrenceDays: z.array(z.number().int().min(0).max(6)).min(1),
});
export const clientPermanentOrderSchema = baseOrderCreateSchema.extend({
startDate: isoDateSchema,
endDate: isoDateSchema.optional(),
daysOfWeek: z.array(z.number().int().min(0).max(6)).min(1).optional(),
horizonDays: z.number().int().min(7).max(180).optional(),
});
export const clientOrderEditSchema = z.object({
orderId: z.string().uuid(),
orderType: z.enum(['ONE_TIME', 'RECURRING', 'PERMANENT']).optional(),
hubId: z.string().uuid().optional(),
vendorId: z.string().uuid().optional(),
eventName: z.string().min(2).max(160).optional(),
orderDate: isoDateSchema.optional(),
startDate: isoDateSchema.optional(),
endDate: isoDateSchema.optional(),
recurrenceDays: z.array(z.number().int().min(0).max(6)).min(1).optional(),
daysOfWeek: z.array(z.number().int().min(0).max(6)).min(1).optional(),
timezone: z.string().min(1).max(80).optional(),
description: z.string().max(5000).optional(),
notes: z.string().max(5000).optional(),
serviceType: z.enum(['EVENT', 'CATERING', 'HOTEL', 'RESTAURANT', 'OTHER']).optional(),
positions: z.array(shiftPositionSchema).min(1).optional(),
metadata: z.record(z.any()).optional(),
}).superRefine((value, ctx) => {
const keys = Object.keys(value).filter((key) => key !== 'orderId');
if (keys.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'At least one field must be provided to create an edited order copy',
path: [],
});
}
});
export const clientOrderCancelSchema = z.object({
orderId: z.string().uuid(),
reason: z.string().max(1000).optional(),
metadata: z.record(z.any()).optional(),
});
export const availabilityDayUpdateSchema = z.object({
dayOfWeek: z.number().int().min(0).max(6),
availabilityStatus: z.enum(['AVAILABLE', 'UNAVAILABLE', 'PARTIAL']),
slots: z.array(timeSlotSchema).max(8).optional(),
metadata: z.record(z.any()).optional(),
});
export const availabilityQuickSetSchema = z.object({
startDate: z.string().datetime().optional(),
endDate: z.string().datetime().optional(),
quickSetType: z.enum(['all', 'weekdays', 'weekends', 'clear']),
slots: z.array(timeSlotSchema).max(8).optional(),
});
export const shiftApplySchema = z.object({
shiftId: z.string().uuid(),
roleId: z.string().uuid().optional(),
instantBook: z.boolean().optional(),
});
export const shiftDecisionSchema = z.object({
shiftId: z.string().uuid(),
reason: z.string().max(1000).optional(),
});
export const staffClockInSchema = z.object({
assignmentId: z.string().uuid().optional(),
shiftId: z.string().uuid().optional(),
sourceType: z.enum(['NFC', 'GEO', 'QR', 'MANUAL', 'SYSTEM']).optional(),
sourceReference: z.string().max(255).optional(),
nfcTagId: z.string().max(255).optional(),
deviceId: z.string().max(255).optional(),
latitude: z.number().min(-90).max(90).optional(),
longitude: z.number().min(-180).max(180).optional(),
accuracyMeters: z.number().int().nonnegative().optional(),
capturedAt: z.string().datetime().optional(),
notes: z.string().max(2000).optional(),
rawPayload: z.record(z.any()).optional(),
}).refine((value) => value.assignmentId || value.shiftId, {
message: 'assignmentId or shiftId is required',
});
export const staffClockOutSchema = z.object({
assignmentId: z.string().uuid().optional(),
shiftId: z.string().uuid().optional(),
applicationId: z.string().uuid().optional(),
sourceType: z.enum(['NFC', 'GEO', 'QR', 'MANUAL', 'SYSTEM']).optional(),
sourceReference: z.string().max(255).optional(),
nfcTagId: z.string().max(255).optional(),
deviceId: z.string().max(255).optional(),
latitude: z.number().min(-90).max(90).optional(),
longitude: z.number().min(-180).max(180).optional(),
accuracyMeters: z.number().int().nonnegative().optional(),
capturedAt: z.string().datetime().optional(),
notes: z.string().max(2000).optional(),
breakMinutes: z.number().int().nonnegative().optional(),
rawPayload: z.record(z.any()).optional(),
}).refine((value) => value.assignmentId || value.shiftId || value.applicationId, {
message: 'assignmentId, shiftId, or applicationId is required',
});
export const staffProfileSetupSchema = z.object({
fullName: z.string().min(2).max(160),
bio: z.string().max(5000).optional(),
email: z.string().email().optional(),
phoneNumber: z.string().min(6).max(40),
preferredLocations: z.array(preferredLocationSchema).max(20).optional(),
maxDistanceMiles: z.number().nonnegative().max(500).optional(),
industries: z.array(z.string().min(1).max(80)).max(30).optional(),
skills: z.array(z.string().min(1).max(80)).max(50).optional(),
primaryRole: z.string().max(120).optional(),
tenantId: z.string().uuid().optional(),
vendorId: z.string().uuid().optional(),
});
export const personalInfoUpdateSchema = z.object({
firstName: z.string().min(1).max(80).optional(),
lastName: z.string().min(1).max(80).optional(),
bio: z.string().max(5000).optional(),
preferredLocations: z.array(preferredLocationSchema).max(20).optional(),
maxDistanceMiles: z.number().nonnegative().max(500).optional(),
email: z.string().email().optional(),
phone: z.string().min(6).max(40).optional(),
displayName: z.string().min(2).max(160).optional(),
});
export const profileExperienceSchema = z.object({
industries: z.array(z.string().min(1).max(80)).max(30).optional(),
skills: z.array(z.string().min(1).max(80)).max(50).optional(),
primaryRole: z.string().max(120).optional(),
});
export const preferredLocationsUpdateSchema = z.object({
preferredLocations: z.array(preferredLocationSchema).max(20),
maxDistanceMiles: z.number().nonnegative().max(500).optional(),
});
export const emergencyContactCreateSchema = z.object({
fullName: z.string().min(2).max(160),
phone: z.string().min(6).max(40),
relationshipType: z.string().min(1).max(120),
isPrimary: z.boolean().optional(),
metadata: z.record(z.any()).optional(),
});
export const emergencyContactUpdateSchema = emergencyContactCreateSchema.partial().extend({
contactId: z.string().uuid(),
});
const taxFormFieldsSchema = z.record(z.any());
export const taxFormDraftSchema = z.object({
formType: z.enum(['I9', 'W4']),
fields: taxFormFieldsSchema,
});
export const taxFormSubmitSchema = z.object({
formType: z.enum(['I9', 'W4']),
fields: taxFormFieldsSchema,
});
export const bankAccountCreateSchema = z.object({
bankName: z.string().min(2).max(160),
accountNumber: z.string().min(4).max(34),
routingNumber: z.string().min(4).max(20),
accountType: z.string()
.transform((value) => value.trim().toUpperCase())
.pipe(z.enum(['CHECKING', 'SAVINGS'])),
});
export const privacyUpdateSchema = z.object({
profileVisible: z.boolean(),
});

View File

@@ -1,6 +1,7 @@
import { z } from 'zod';
const roleSchema = z.object({
roleId: z.string().uuid().optional(),
roleCode: z.string().min(1).max(100),
roleName: z.string().min(1).max(120),
workersNeeded: z.number().int().positive(),

View File

@@ -0,0 +1,412 @@
import { Router } from 'express';
import { AppError } from '../lib/errors.js';
import { requireAuth, requirePolicy } from '../middleware/auth.js';
import { requireIdempotencyKey } from '../middleware/idempotency.js';
import { buildIdempotencyKey, readIdempotentResult, writeIdempotentResult } from '../services/idempotency-store.js';
import {
addStaffBankAccount,
approveInvoice,
applyForShift,
assignHubManager,
assignHubNfc,
cancelLateWorker,
cancelClientOrder,
createEmergencyContact,
createClientOneTimeOrder,
createClientPermanentOrder,
createClientRecurringOrder,
createEditedOrderCopy,
createHub,
declinePendingShift,
disputeInvoice,
quickSetStaffAvailability,
rateWorkerFromCoverage,
requestShiftSwap,
saveTaxFormDraft,
setupStaffProfile,
staffClockIn,
staffClockOut,
submitTaxForm,
updateEmergencyContact,
updateHub,
updatePersonalInfo,
updatePreferredLocations,
updatePrivacyVisibility,
updateProfileExperience,
updateStaffAvailabilityDay,
deleteHub,
acceptPendingShift,
} from '../services/mobile-command-service.js';
import {
availabilityDayUpdateSchema,
availabilityQuickSetSchema,
bankAccountCreateSchema,
cancelLateWorkerSchema,
clientOneTimeOrderSchema,
clientOrderCancelSchema,
clientOrderEditSchema,
clientPermanentOrderSchema,
clientRecurringOrderSchema,
coverageReviewSchema,
emergencyContactCreateSchema,
emergencyContactUpdateSchema,
hubAssignManagerSchema,
hubAssignNfcSchema,
hubCreateSchema,
hubDeleteSchema,
hubUpdateSchema,
invoiceApproveSchema,
invoiceDisputeSchema,
personalInfoUpdateSchema,
preferredLocationsUpdateSchema,
privacyUpdateSchema,
profileExperienceSchema,
shiftApplySchema,
shiftDecisionSchema,
staffClockInSchema,
staffClockOutSchema,
staffProfileSetupSchema,
taxFormDraftSchema,
taxFormSubmitSchema,
} from '../contracts/commands/mobile.js';
const defaultHandlers = {
acceptPendingShift,
addStaffBankAccount,
approveInvoice,
applyForShift,
assignHubManager,
assignHubNfc,
cancelLateWorker,
cancelClientOrder,
createEmergencyContact,
createClientOneTimeOrder,
createClientPermanentOrder,
createClientRecurringOrder,
createEditedOrderCopy,
createHub,
declinePendingShift,
disputeInvoice,
quickSetStaffAvailability,
rateWorkerFromCoverage,
requestShiftSwap,
saveTaxFormDraft,
setupStaffProfile,
staffClockIn,
staffClockOut,
submitTaxForm,
updateEmergencyContact,
updateHub,
updatePersonalInfo,
updatePreferredLocations,
updatePrivacyVisibility,
updateProfileExperience,
updateStaffAvailabilityDay,
deleteHub,
};
function parseBody(schema, body) {
const parsed = schema.safeParse(body || {});
if (!parsed.success) {
throw new AppError('VALIDATION_ERROR', 'Invalid request payload', 400, {
issues: parsed.error.issues,
});
}
return parsed.data;
}
async function runIdempotentCommand(req, res, work) {
const route = `${req.baseUrl}${req.route.path}`;
const compositeKey = buildIdempotencyKey({
userId: req.actor.uid,
route,
idempotencyKey: req.idempotencyKey,
});
const existing = await readIdempotentResult(compositeKey);
if (existing) {
return res.status(existing.statusCode).json(existing.payload);
}
const payload = await work();
const responsePayload = {
...payload,
idempotencyKey: req.idempotencyKey,
requestId: req.requestId,
};
const persisted = await writeIdempotentResult({
compositeKey,
userId: req.actor.uid,
route,
idempotencyKey: req.idempotencyKey,
payload: responsePayload,
statusCode: 200,
});
return res.status(persisted.statusCode).json(persisted.payload);
}
function mobileCommand(route, { schema, policyAction, resource, handler, paramShape }) {
return [
route,
requireAuth,
requireIdempotencyKey,
requirePolicy(policyAction, resource),
async (req, res, next) => {
try {
const body = typeof paramShape === 'function'
? paramShape(req)
: req.body;
const payload = parseBody(schema, body);
return await runIdempotentCommand(req, res, () => handler(req.actor, payload));
} catch (error) {
return next(error);
}
},
];
}
export function createMobileCommandsRouter(handlers = defaultHandlers) {
const router = Router();
router.post(...mobileCommand('/client/orders/one-time', {
schema: clientOneTimeOrderSchema,
policyAction: 'orders.create',
resource: 'order',
handler: handlers.createClientOneTimeOrder,
}));
router.post(...mobileCommand('/client/orders/recurring', {
schema: clientRecurringOrderSchema,
policyAction: 'orders.create',
resource: 'order',
handler: handlers.createClientRecurringOrder,
}));
router.post(...mobileCommand('/client/orders/permanent', {
schema: clientPermanentOrderSchema,
policyAction: 'orders.create',
resource: 'order',
handler: handlers.createClientPermanentOrder,
}));
router.post(...mobileCommand('/client/orders/:orderId/edit', {
schema: clientOrderEditSchema,
policyAction: 'orders.update',
resource: 'order',
handler: handlers.createEditedOrderCopy,
paramShape: (req) => ({ ...req.body, orderId: req.params.orderId }),
}));
router.post(...mobileCommand('/client/orders/:orderId/cancel', {
schema: clientOrderCancelSchema,
policyAction: 'orders.cancel',
resource: 'order',
handler: handlers.cancelClientOrder,
paramShape: (req) => ({ ...req.body, orderId: req.params.orderId }),
}));
router.post(...mobileCommand('/client/hubs', {
schema: hubCreateSchema,
policyAction: 'client.hubs.create',
resource: 'hub',
handler: handlers.createHub,
}));
router.put(...mobileCommand('/client/hubs/:hubId', {
schema: hubUpdateSchema,
policyAction: 'client.hubs.update',
resource: 'hub',
handler: handlers.updateHub,
paramShape: (req) => ({ ...req.body, hubId: req.params.hubId }),
}));
router.delete(...mobileCommand('/client/hubs/:hubId', {
schema: hubDeleteSchema,
policyAction: 'client.hubs.delete',
resource: 'hub',
handler: handlers.deleteHub,
paramShape: (req) => ({ ...req.body, hubId: req.params.hubId }),
}));
router.post(...mobileCommand('/client/hubs/:hubId/assign-nfc', {
schema: hubAssignNfcSchema,
policyAction: 'client.hubs.update',
resource: 'hub',
handler: handlers.assignHubNfc,
paramShape: (req) => ({ ...req.body, hubId: req.params.hubId }),
}));
router.post(...mobileCommand('/client/hubs/:hubId/managers', {
schema: hubAssignManagerSchema,
policyAction: 'client.hubs.update',
resource: 'hub',
handler: handlers.assignHubManager,
paramShape: (req) => ({ ...req.body, hubId: req.params.hubId }),
}));
router.post(...mobileCommand('/client/billing/invoices/:invoiceId/approve', {
schema: invoiceApproveSchema,
policyAction: 'client.billing.write',
resource: 'invoice',
handler: handlers.approveInvoice,
paramShape: (req) => ({ invoiceId: req.params.invoiceId }),
}));
router.post(...mobileCommand('/client/billing/invoices/:invoiceId/dispute', {
schema: invoiceDisputeSchema,
policyAction: 'client.billing.write',
resource: 'invoice',
handler: handlers.disputeInvoice,
paramShape: (req) => ({ ...req.body, invoiceId: req.params.invoiceId }),
}));
router.post(...mobileCommand('/client/coverage/reviews', {
schema: coverageReviewSchema,
policyAction: 'client.coverage.write',
resource: 'staff_review',
handler: handlers.rateWorkerFromCoverage,
}));
router.post(...mobileCommand('/client/coverage/late-workers/:assignmentId/cancel', {
schema: cancelLateWorkerSchema,
policyAction: 'client.coverage.write',
resource: 'assignment',
handler: handlers.cancelLateWorker,
paramShape: (req) => ({ ...req.body, assignmentId: req.params.assignmentId }),
}));
router.post(...mobileCommand('/staff/profile/setup', {
schema: staffProfileSetupSchema,
policyAction: 'staff.profile.write',
resource: 'staff',
handler: handlers.setupStaffProfile,
}));
router.post(...mobileCommand('/staff/clock-in', {
schema: staffClockInSchema,
policyAction: 'attendance.clock-in',
resource: 'attendance',
handler: handlers.staffClockIn,
}));
router.post(...mobileCommand('/staff/clock-out', {
schema: staffClockOutSchema,
policyAction: 'attendance.clock-out',
resource: 'attendance',
handler: handlers.staffClockOut,
}));
router.put(...mobileCommand('/staff/availability', {
schema: availabilityDayUpdateSchema,
policyAction: 'staff.availability.write',
resource: 'staff',
handler: handlers.updateStaffAvailabilityDay,
}));
router.post(...mobileCommand('/staff/availability/quick-set', {
schema: availabilityQuickSetSchema,
policyAction: 'staff.availability.write',
resource: 'staff',
handler: handlers.quickSetStaffAvailability,
}));
router.post(...mobileCommand('/staff/shifts/:shiftId/apply', {
schema: shiftApplySchema,
policyAction: 'staff.shifts.apply',
resource: 'shift',
handler: handlers.applyForShift,
paramShape: (req) => ({ ...req.body, shiftId: req.params.shiftId }),
}));
router.post(...mobileCommand('/staff/shifts/:shiftId/accept', {
schema: shiftDecisionSchema,
policyAction: 'staff.shifts.accept',
resource: 'shift',
handler: handlers.acceptPendingShift,
paramShape: (req) => ({ ...req.body, shiftId: req.params.shiftId }),
}));
router.post(...mobileCommand('/staff/shifts/:shiftId/decline', {
schema: shiftDecisionSchema,
policyAction: 'staff.shifts.decline',
resource: 'shift',
handler: handlers.declinePendingShift,
paramShape: (req) => ({ ...req.body, shiftId: req.params.shiftId }),
}));
router.post(...mobileCommand('/staff/shifts/:shiftId/request-swap', {
schema: shiftDecisionSchema,
policyAction: 'staff.shifts.swap',
resource: 'shift',
handler: handlers.requestShiftSwap,
paramShape: (req) => ({ ...req.body, shiftId: req.params.shiftId }),
}));
router.put(...mobileCommand('/staff/profile/personal-info', {
schema: personalInfoUpdateSchema,
policyAction: 'staff.profile.write',
resource: 'staff',
handler: handlers.updatePersonalInfo,
}));
router.put(...mobileCommand('/staff/profile/experience', {
schema: profileExperienceSchema,
policyAction: 'staff.profile.write',
resource: 'staff',
handler: handlers.updateProfileExperience,
}));
router.put(...mobileCommand('/staff/profile/locations', {
schema: preferredLocationsUpdateSchema,
policyAction: 'staff.profile.write',
resource: 'staff',
handler: handlers.updatePreferredLocations,
}));
router.post(...mobileCommand('/staff/profile/emergency-contacts', {
schema: emergencyContactCreateSchema,
policyAction: 'staff.profile.write',
resource: 'staff',
handler: handlers.createEmergencyContact,
}));
router.put(...mobileCommand('/staff/profile/emergency-contacts/:contactId', {
schema: emergencyContactUpdateSchema,
policyAction: 'staff.profile.write',
resource: 'staff',
handler: handlers.updateEmergencyContact,
paramShape: (req) => ({ ...req.body, contactId: req.params.contactId }),
}));
router.put(...mobileCommand('/staff/profile/tax-forms/:formType', {
schema: taxFormDraftSchema,
policyAction: 'staff.profile.write',
resource: 'staff_document',
handler: handlers.saveTaxFormDraft,
paramShape: (req) => ({ ...req.body, formType: `${req.params.formType}`.toUpperCase() }),
}));
router.post(...mobileCommand('/staff/profile/tax-forms/:formType/submit', {
schema: taxFormSubmitSchema,
policyAction: 'staff.profile.write',
resource: 'staff_document',
handler: handlers.submitTaxForm,
paramShape: (req) => ({ ...req.body, formType: `${req.params.formType}`.toUpperCase() }),
}));
router.post(...mobileCommand('/staff/profile/bank-accounts', {
schema: bankAccountCreateSchema,
policyAction: 'staff.profile.write',
resource: 'account',
handler: handlers.addStaffBankAccount,
}));
router.put(...mobileCommand('/staff/profile/privacy', {
schema: privacyUpdateSchema,
policyAction: 'staff.profile.write',
resource: 'staff',
handler: handlers.updatePrivacyVisibility,
}));
return router;
}

View File

@@ -0,0 +1,111 @@
import { AppError } from '../lib/errors.js';
import { query } from './db.js';
export async function loadActorContext(uid) {
const [userResult, tenantResult, businessResult, vendorResult, staffResult] = await Promise.all([
query(
`
SELECT id AS "userId", email, display_name AS "displayName", phone, status
FROM users
WHERE id = $1
`,
[uid]
),
query(
`
SELECT tm.id AS "membershipId",
tm.tenant_id AS "tenantId",
tm.base_role AS role,
t.name AS "tenantName",
t.slug AS "tenantSlug"
FROM tenant_memberships tm
JOIN tenants t ON t.id = tm.tenant_id
WHERE tm.user_id = $1
AND tm.membership_status = 'ACTIVE'
ORDER BY tm.created_at ASC
LIMIT 1
`,
[uid]
),
query(
`
SELECT bm.id AS "membershipId",
bm.business_id AS "businessId",
bm.business_role AS role,
b.business_name AS "businessName",
b.slug AS "businessSlug",
bm.tenant_id AS "tenantId"
FROM business_memberships bm
JOIN businesses b ON b.id = bm.business_id
WHERE bm.user_id = $1
AND bm.membership_status = 'ACTIVE'
ORDER BY bm.created_at ASC
LIMIT 1
`,
[uid]
),
query(
`
SELECT vm.id AS "membershipId",
vm.vendor_id AS "vendorId",
vm.vendor_role AS role,
v.company_name AS "vendorName",
v.slug AS "vendorSlug",
vm.tenant_id AS "tenantId"
FROM vendor_memberships vm
JOIN vendors v ON v.id = vm.vendor_id
WHERE vm.user_id = $1
AND vm.membership_status = 'ACTIVE'
ORDER BY vm.created_at ASC
LIMIT 1
`,
[uid]
),
query(
`
SELECT s.id AS "staffId",
s.tenant_id AS "tenantId",
s.full_name AS "fullName",
s.email,
s.phone,
s.primary_role AS "primaryRole",
s.onboarding_status AS "onboardingStatus",
s.status,
s.metadata,
w.id AS "workforceId",
w.vendor_id AS "vendorId",
w.workforce_number AS "workforceNumber"
FROM staffs s
LEFT JOIN workforce w ON w.staff_id = s.id
WHERE s.user_id = $1
ORDER BY s.created_at ASC
LIMIT 1
`,
[uid]
),
]);
return {
user: userResult.rows[0] || null,
tenant: tenantResult.rows[0] || null,
business: businessResult.rows[0] || null,
vendor: vendorResult.rows[0] || null,
staff: staffResult.rows[0] || null,
};
}
export async function requireClientContext(uid) {
const context = await loadActorContext(uid);
if (!context.user || !context.tenant || !context.business) {
throw new AppError('FORBIDDEN', 'Client business context is required for this route', 403, { uid });
}
return context;
}
export async function requireStaffContext(uid) {
const context = await loadActorContext(uid);
if (!context.user || !context.tenant || !context.staff) {
throw new AppError('FORBIDDEN', 'Staff context is required for this route', 403, { uid });
}
return context;
}

View File

@@ -562,6 +562,7 @@ export async function createOrder(actor, payload) {
`
INSERT INTO shift_roles (
shift_id,
role_id,
role_code,
role_name,
workers_needed,
@@ -570,10 +571,11 @@ export async function createOrder(actor, payload) {
bill_rate_cents,
metadata
)
VALUES ($1, $2, $3, $4, 0, $5, $6, $7::jsonb)
VALUES ($1, $2, $3, $4, $5, 0, $6, $7, $8::jsonb)
`,
[
shift.id,
roleInput.roleId || null,
roleInput.roleCode,
roleInput.roleName,
roleInput.workersNeeded,

View File

@@ -1,4 +1,15 @@
import { Pool } from 'pg';
import pg from 'pg';
const { Pool, types } = pg;
function parseNumericDatabaseValue(value) {
if (value == null) return value;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : value;
}
types.setTypeParser(types.builtins.INT8, parseNumericDatabaseValue);
types.setTypeParser(types.builtins.NUMERIC, parseNumericDatabaseValue);
let pool;

File diff suppressed because it is too large Load Diff