diff --git a/backend/command-api/scripts/seed-v2-demo-data.mjs b/backend/command-api/scripts/seed-v2-demo-data.mjs index 94a41e0d..f87bd78f 100644 --- a/backend/command-api/scripts/seed-v2-demo-data.mjs +++ b/backend/command-api/scripts/seed-v2-demo-data.mjs @@ -44,6 +44,14 @@ async function main() { const completedEndsAt = hoursFromNow(-20); const checkedInAt = hoursFromNow(-27.5); const checkedOutAt = hoursFromNow(-20.25); + const assignedStartsAt = hoursFromNow(2); + const assignedEndsAt = hoursFromNow(10); + const availableStartsAt = hoursFromNow(30); + const availableEndsAt = hoursFromNow(38); + const cancelledStartsAt = hoursFromNow(20); + const cancelledEndsAt = hoursFromNow(28); + const noShowStartsAt = hoursFromNow(-18); + const noShowEndsAt = hoursFromNow(-10); const invoiceDueAt = hoursFromNow(72); await upsertUser(client, fixture.users.businessOwner); @@ -248,6 +256,16 @@ async function main() { [fixture.benefits.commuter.id, fixture.tenant.id, fixture.staff.ana.id, fixture.benefits.commuter.title] ); + await client.query( + ` + INSERT INTO emergency_contacts ( + id, tenant_id, staff_id, full_name, phone, relationship_type, is_primary, metadata + ) + VALUES ($1, $2, $3, 'Maria Barista', '+15550007777', 'SIBLING', TRUE, '{"seeded":true}'::jsonb) + `, + [fixture.emergencyContacts.primary.id, fixture.tenant.id, fixture.staff.ana.id] + ); + await client.query( ` INSERT INTO clock_points ( @@ -319,6 +337,34 @@ async function main() { ] ); + await client.query( + ` + INSERT INTO orders ( + id, tenant_id, business_id, vendor_id, order_number, title, description, status, service_type, + starts_at, ends_at, location_name, location_address, latitude, longitude, notes, created_by_user_id, metadata + ) + VALUES ( + $1, $2, $3, $4, $5, $6, 'Active order used to populate assigned, available, cancelled, and no-show shift states', + 'ACTIVE', 'RESTAURANT', $7, $8, 'Google Cafe', $9, $10, $11, 'Mixed state scenario order', $12, + '{"slice":"active","orderType":"ONE_TIME"}'::jsonb + ) + `, + [ + fixture.orders.active.id, + fixture.tenant.id, + fixture.business.id, + fixture.vendor.id, + fixture.orders.active.number, + fixture.orders.active.title, + assignedStartsAt, + availableEndsAt, + fixture.clockPoint.address, + fixture.clockPoint.latitude, + fixture.clockPoint.longitude, + fixture.users.operationsManager.id, + ] + ); + await client.query( ` INSERT INTO shifts ( @@ -357,6 +403,51 @@ async function main() { ] ); + await client.query( + ` + INSERT INTO shifts ( + id, tenant_id, order_id, business_id, vendor_id, clock_point_id, shift_code, title, status, starts_at, ends_at, timezone, + location_name, location_address, latitude, longitude, geofence_radius_meters, required_workers, assigned_workers, notes, metadata + ) + VALUES + ($1, $2, $3, $4, $5, $6, $7, $8, 'OPEN', $9, $10, 'America/Los_Angeles', 'Google Cafe', $11, $12, $13, $14, 1, 0, 'Available shift for staff marketplace', '{"slice":"available"}'::jsonb), + ($15, $2, $3, $4, $5, $6, $16, $17, 'ASSIGNED', $18, $19, 'America/Los_Angeles', 'Google Cafe', $11, $12, $13, $14, 1, 1, 'Assigned shift waiting for staff confirmation', '{"slice":"assigned"}'::jsonb), + ($20, $2, $3, $4, $5, $6, $21, $22, 'CANCELLED', $23, $24, 'America/Los_Angeles', 'Google Cafe', $11, $12, $13, $14, 1, 0, 'Cancelled shift history sample', '{"slice":"cancelled"}'::jsonb), + ($25, $2, $3, $4, $5, $6, $26, $27, 'COMPLETED', $28, $29, 'America/Los_Angeles', 'Google Cafe', $11, $12, $13, $14, 1, 0, 'No-show historical sample', '{"slice":"no_show"}'::jsonb) + `, + [ + fixture.shifts.available.id, + fixture.tenant.id, + fixture.orders.active.id, + fixture.business.id, + fixture.vendor.id, + fixture.clockPoint.id, + fixture.shifts.available.code, + fixture.shifts.available.title, + availableStartsAt, + availableEndsAt, + fixture.clockPoint.address, + fixture.clockPoint.latitude, + fixture.clockPoint.longitude, + fixture.clockPoint.geofenceRadiusMeters, + fixture.shifts.assigned.id, + fixture.shifts.assigned.code, + fixture.shifts.assigned.title, + assignedStartsAt, + assignedEndsAt, + fixture.shifts.cancelled.id, + fixture.shifts.cancelled.code, + fixture.shifts.cancelled.title, + cancelledStartsAt, + cancelledEndsAt, + fixture.shifts.noShow.id, + fixture.shifts.noShow.code, + fixture.shifts.noShow.title, + noShowStartsAt, + noShowEndsAt, + ] + ); + await client.query( ` INSERT INTO shift_roles ( @@ -377,6 +468,32 @@ async function main() { ] ); + await client.query( + ` + INSERT INTO shift_roles ( + id, shift_id, role_id, role_code, role_name, workers_needed, assigned_count, pay_rate_cents, bill_rate_cents, metadata + ) + VALUES + ($1, $2, $7, $8, $9, 1, 0, 2200, 3500, '{"slice":"available"}'::jsonb), + ($3, $4, $7, $8, $9, 1, 1, 2300, 3600, '{"slice":"assigned"}'::jsonb), + ($5, $6, $7, $8, $9, 1, 0, 2200, 3500, '{"slice":"cancelled"}'::jsonb), + ($10, $11, $7, $8, $9, 1, 0, 2200, 3500, '{"slice":"no_show"}'::jsonb) + `, + [ + fixture.shiftRoles.availableBarista.id, + fixture.shifts.available.id, + fixture.shiftRoles.assignedBarista.id, + fixture.shifts.assigned.id, + fixture.shiftRoles.cancelledBarista.id, + fixture.shifts.cancelled.id, + fixture.roles.barista.id, + fixture.roles.barista.code, + fixture.roles.barista.name, + fixture.shiftRoles.noShowBarista.id, + fixture.shifts.noShow.id, + ] + ); + await client.query( ` INSERT INTO applications ( @@ -417,6 +534,36 @@ async function main() { ] ); + await client.query( + ` + INSERT INTO assignments ( + id, tenant_id, business_id, vendor_id, shift_id, shift_role_id, workforce_id, staff_id, status, + assigned_at, accepted_at, checked_in_at, checked_out_at, metadata + ) + VALUES + ($1, $2, $3, $4, $5, $6, $7, $8, 'ASSIGNED', NOW(), NULL, NULL, NULL, '{"slice":"assigned"}'::jsonb), + ($9, $2, $3, $4, $10, $11, $7, $8, 'CANCELLED', NOW(), NULL, NULL, NULL, '{"slice":"cancelled","cancellationReason":"Client cancelled"}'::jsonb), + ($12, $2, $3, $4, $13, $14, $7, $8, 'NO_SHOW', $15, NULL, NULL, NULL, '{"slice":"no_show"}'::jsonb) + `, + [ + fixture.assignments.assignedAna.id, + fixture.tenant.id, + fixture.business.id, + fixture.vendor.id, + fixture.shifts.assigned.id, + fixture.shiftRoles.assignedBarista.id, + fixture.workforce.ana.id, + fixture.staff.ana.id, + fixture.assignments.cancelledAna.id, + fixture.shifts.cancelled.id, + fixture.shiftRoles.cancelledBarista.id, + fixture.assignments.noShowAna.id, + fixture.shifts.noShow.id, + fixture.shiftRoles.noShowBarista.id, + noShowStartsAt, + ] + ); + await client.query( ` INSERT INTO attendance_events ( @@ -486,50 +633,69 @@ async function main() { ` INSERT INTO documents (id, tenant_id, document_type, name, required_for_role_code, metadata) VALUES - ($1, $2, 'CERTIFICATION', $3, $6, '{"seeded":true}'::jsonb), - ($4, $2, 'ATTIRE', $5, $6, '{"seeded":true}'::jsonb), - ($7, $2, 'TAX_FORM', $8, $6, '{"seeded":true}'::jsonb) + ($1, $2, 'GOVERNMENT_ID', $3, $10, '{"seeded":true,"description":"State ID or passport","required":true}'::jsonb), + ($4, $2, 'CERTIFICATION', $5, $10, '{"seeded":true}'::jsonb), + ($6, $2, 'ATTIRE', $7, $10, '{"seeded":true,"description":"Upload a photo of your black shirt","required":true}'::jsonb), + ($8, $2, 'TAX_FORM', $9, $10, '{"seeded":true}'::jsonb), + ($11, $2, 'TAX_FORM', $12, $10, '{"seeded":true}'::jsonb) `, [ - fixture.documents.foodSafety.id, + fixture.documents.governmentId.id, fixture.tenant.id, + fixture.documents.governmentId.name, + fixture.documents.foodSafety.id, fixture.documents.foodSafety.name, fixture.documents.attireBlackShirt.id, fixture.documents.attireBlackShirt.name, + fixture.documents.taxFormI9.id, + fixture.documents.taxFormI9.name, fixture.roles.barista.code, - fixture.documents.taxFormW9.id, - fixture.documents.taxFormW9.name, + fixture.documents.taxFormW4.id, + fixture.documents.taxFormW4.name, ] ); await client.query( ` - INSERT INTO staff_documents (id, tenant_id, staff_id, document_id, file_uri, status, expires_at, metadata) + INSERT INTO staff_documents ( + id, tenant_id, staff_id, document_id, file_uri, status, expires_at, metadata + ) VALUES - ($1, $2, $3, $4, $5, 'VERIFIED', $6, '{"seeded":true}'::jsonb), - ($7, $2, $3, $8, $9, 'VERIFIED', NULL, '{"seeded":true}'::jsonb), - ($10, $2, $3, $11, $12, 'VERIFIED', NULL, '{"seeded":true}'::jsonb) + ($1, $2, $3, $4, $5, 'PENDING', $6, '{"seeded":true,"verificationStatus":"PENDING_REVIEW"}'::jsonb), + ($7, $2, $3, $8, $9, 'VERIFIED', $10, '{"seeded":true,"verificationStatus":"APPROVED"}'::jsonb), + ($11, $2, $3, $12, $13, 'VERIFIED', NULL, '{"seeded":true,"verificationStatus":"APPROVED"}'::jsonb), + ($14, $2, $3, $15, $16, 'VERIFIED', NULL, '{"seeded":true,"formStatus":"SUBMITTED","fields":{"ssnLast4":"1234","filingStatus":"single"}}'::jsonb), + ($17, $2, $3, $18, $19, 'PENDING', NULL, '{"seeded":true,"formStatus":"DRAFT","fields":{"section1Complete":true}}'::jsonb) `, [ - fixture.staffDocuments.foodSafety.id, + fixture.staffDocuments.governmentId.id, fixture.tenant.id, fixture.staff.ana.id, + fixture.documents.governmentId.id, + `gs://krow-workforce-dev-v2-private/uploads/${fixture.staff.ana.id}/government-id-front.jpg`, + hoursFromNow(24 * 365), + fixture.staffDocuments.foodSafety.id, fixture.documents.foodSafety.id, `gs://krow-workforce-dev-v2-private/uploads/${fixture.staff.ana.id}/food-handler-card.pdf`, hoursFromNow(24 * 180), fixture.staffDocuments.attireBlackShirt.id, fixture.documents.attireBlackShirt.id, `gs://krow-workforce-dev-v2-private/uploads/${fixture.staff.ana.id}/black-shirt.jpg`, - fixture.staffDocuments.taxFormW9.id, - fixture.documents.taxFormW9.id, - `gs://krow-workforce-dev-v2-private/uploads/${fixture.staff.ana.id}/w9-form.pdf`, + fixture.staffDocuments.taxFormW4.id, + fixture.documents.taxFormW4.id, + `gs://krow-workforce-dev-v2-private/uploads/${fixture.staff.ana.id}/w4-form.pdf`, + fixture.staffDocuments.taxFormI9.id, + fixture.documents.taxFormI9.id, + `gs://krow-workforce-dev-v2-private/uploads/${fixture.staff.ana.id}/i9-form.pdf`, ] ); await client.query( ` - INSERT INTO certificates (id, tenant_id, staff_id, certificate_type, certificate_number, issued_at, expires_at, status, metadata) - VALUES ($1, $2, $3, 'FOOD_SAFETY', 'FH-ANA-2026', $4, $5, 'VERIFIED', '{"seeded":true}'::jsonb) + INSERT INTO certificates ( + id, tenant_id, staff_id, certificate_type, certificate_number, issued_at, expires_at, status, file_uri, metadata + ) + VALUES ($1, $2, $3, 'FOOD_SAFETY', 'FH-ANA-2026', $4, $5, 'VERIFIED', $6, $7::jsonb) `, [ fixture.certificates.foodSafety.id, @@ -537,6 +703,13 @@ async function main() { fixture.staff.ana.id, hoursFromNow(-24 * 30), hoursFromNow(24 * 180), + `gs://krow-workforce-dev-v2-private/uploads/${fixture.staff.ana.id}/food-safety-certificate.pdf`, + JSON.stringify({ + seeded: true, + name: 'Food Safety Certificate', + issuer: 'ServSafe', + verificationStatus: 'APPROVED', + }), ] ); diff --git a/backend/command-api/scripts/v2-demo-fixture.mjs b/backend/command-api/scripts/v2-demo-fixture.mjs index 369ede17..77f22ee5 100644 --- a/backend/command-api/scripts/v2-demo-fixture.mjs +++ b/backend/command-api/scripts/v2-demo-fixture.mjs @@ -108,6 +108,11 @@ export const V2DemoFixture = { number: 'ORD-V2-COMP-1002', title: 'Completed catering shift', }, + active: { + id: 'b6132d7a-45c3-4879-b349-46b2fd518003', + number: 'ORD-V2-ACT-1003', + title: 'Live staffing operations', + }, }, shifts: { open: { @@ -120,6 +125,26 @@ export const V2DemoFixture = { code: 'SHIFT-V2-COMP-1', title: 'Completed catering shift', }, + available: { + id: '6e7dadad-99e4-45bb-b0da-7bb617954003', + code: 'SHIFT-V2-OPEN-2', + title: 'Available lunch shift', + }, + assigned: { + id: '6e7dadad-99e4-45bb-b0da-7bb617954004', + code: 'SHIFT-V2-ASSIGNED-1', + title: 'Assigned espresso shift', + }, + cancelled: { + id: '6e7dadad-99e4-45bb-b0da-7bb617954005', + code: 'SHIFT-V2-CANCELLED-1', + title: 'Cancelled hospitality shift', + }, + noShow: { + id: '6e7dadad-99e4-45bb-b0da-7bb617954006', + code: 'SHIFT-V2-NOSHOW-1', + title: 'No-show breakfast shift', + }, }, shiftRoles: { openBarista: { @@ -128,6 +153,18 @@ export const V2DemoFixture = { completedBarista: { id: '4dd35b2b-4aaf-4c28-a91f-7bda05e2b002', }, + availableBarista: { + id: '4dd35b2b-4aaf-4c28-a91f-7bda05e2b003', + }, + assignedBarista: { + id: '4dd35b2b-4aaf-4c28-a91f-7bda05e2b004', + }, + cancelledBarista: { + id: '4dd35b2b-4aaf-4c28-a91f-7bda05e2b005', + }, + noShowBarista: { + id: '4dd35b2b-4aaf-4c28-a91f-7bda05e2b006', + }, }, applications: { openAna: { @@ -138,6 +175,15 @@ export const V2DemoFixture = { completedAna: { id: 'f1d3f738-a132-4863-b222-4f9cb25aa001', }, + assignedAna: { + id: 'f1d3f738-a132-4863-b222-4f9cb25aa002', + }, + cancelledAna: { + id: 'f1d3f738-a132-4863-b222-4f9cb25aa003', + }, + noShowAna: { + id: 'f1d3f738-a132-4863-b222-4f9cb25aa004', + }, }, timesheets: { completedAna: { @@ -166,6 +212,10 @@ export const V2DemoFixture = { }, }, documents: { + governmentId: { + id: 'e6fd0183-34d9-4c23-9a9a-bf98da995000', + name: 'Government ID', + }, foodSafety: { id: 'e6fd0183-34d9-4c23-9a9a-bf98da995001', name: 'Food Handler Card', @@ -174,27 +224,42 @@ export const V2DemoFixture = { id: 'e6fd0183-34d9-4c23-9a9a-bf98da995002', name: 'Black Shirt', }, - taxFormW9: { + taxFormI9: { id: 'e6fd0183-34d9-4c23-9a9a-bf98da995003', - name: 'W-9 Tax Form', + name: 'I-9', + }, + taxFormW4: { + id: 'e6fd0183-34d9-4c23-9a9a-bf98da995004', + name: 'W-4', }, }, staffDocuments: { + governmentId: { + id: '4b157236-a4b0-4c44-b199-7d4ea1f95000', + }, foodSafety: { id: '4b157236-a4b0-4c44-b199-7d4ea1f95001', }, attireBlackShirt: { id: '4b157236-a4b0-4c44-b199-7d4ea1f95002', }, - taxFormW9: { + taxFormI9: { id: '4b157236-a4b0-4c44-b199-7d4ea1f95003', }, + taxFormW4: { + id: '4b157236-a4b0-4c44-b199-7d4ea1f95004', + }, }, certificates: { foodSafety: { id: 'df6452dc-4ec7-4d54-876d-26bf8ce5b001', }, }, + emergencyContacts: { + primary: { + id: '8bb1e0c0-59bb-4ce7-8f0f-27674e0b2001', + }, + }, accounts: { businessPrimary: { id: '5d98e0ba-8e89-4ffb-aafd-df6bbe2fe001', diff --git a/backend/command-api/sql/v2/003_v2_mobile_workflows.sql b/backend/command-api/sql/v2/003_v2_mobile_workflows.sql new file mode 100644 index 00000000..11d02bc0 --- /dev/null +++ b/backend/command-api/sql/v2/003_v2_mobile_workflows.sql @@ -0,0 +1,44 @@ +CREATE TABLE IF NOT EXISTS emergency_contacts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE CASCADE, + full_name TEXT NOT NULL, + phone TEXT NOT NULL, + relationship_type TEXT NOT NULL, + is_primary BOOLEAN NOT NULL DEFAULT FALSE, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_emergency_contacts_staff + ON emergency_contacts (staff_id, created_at DESC); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_emergency_contacts_primary_staff + ON emergency_contacts (staff_id) + WHERE is_primary = TRUE; + +ALTER TABLE assignments + DROP CONSTRAINT IF EXISTS assignments_status_check; + +ALTER TABLE assignments + ADD CONSTRAINT assignments_status_check + CHECK (status IN ('ASSIGNED', 'ACCEPTED', 'SWAP_REQUESTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED', 'CANCELLED', 'NO_SHOW')); + +ALTER TABLE verification_jobs + ADD COLUMN IF NOT EXISTS owner_user_id TEXT REFERENCES users(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS subject_type TEXT, + ADD COLUMN IF NOT EXISTS subject_id TEXT; + +ALTER TABLE staff_documents + ADD COLUMN IF NOT EXISTS verification_job_id UUID REFERENCES verification_jobs(id) ON DELETE SET NULL; + +ALTER TABLE certificates + ADD COLUMN IF NOT EXISTS file_uri TEXT, + ADD COLUMN IF NOT EXISTS verification_job_id UUID REFERENCES verification_jobs(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_verification_jobs_owner + ON verification_jobs (owner_user_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_verification_jobs_subject + ON verification_jobs (subject_type, subject_id, created_at DESC); diff --git a/backend/command-api/src/app.js b/backend/command-api/src/app.js index ef43071c..0c6fa44a 100644 --- a/backend/command-api/src/app.js +++ b/backend/command-api/src/app.js @@ -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); diff --git a/backend/command-api/src/contracts/commands/mobile.js b/backend/command-api/src/contracts/commands/mobile.js new file mode 100644 index 00000000..8a1b2243 --- /dev/null +++ b/backend/command-api/src/contracts/commands/mobile.js @@ -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(), +}); diff --git a/backend/command-api/src/contracts/commands/order-create.js b/backend/command-api/src/contracts/commands/order-create.js index 3ea5df80..5dc672c7 100644 --- a/backend/command-api/src/contracts/commands/order-create.js +++ b/backend/command-api/src/contracts/commands/order-create.js @@ -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(), diff --git a/backend/command-api/src/routes/mobile.js b/backend/command-api/src/routes/mobile.js new file mode 100644 index 00000000..39a6376b --- /dev/null +++ b/backend/command-api/src/routes/mobile.js @@ -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; +} diff --git a/backend/command-api/src/services/actor-context.js b/backend/command-api/src/services/actor-context.js new file mode 100644 index 00000000..30d23aa5 --- /dev/null +++ b/backend/command-api/src/services/actor-context.js @@ -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; +} diff --git a/backend/command-api/src/services/command-service.js b/backend/command-api/src/services/command-service.js index e1aa5ca0..09fba8fe 100644 --- a/backend/command-api/src/services/command-service.js +++ b/backend/command-api/src/services/command-service.js @@ -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, diff --git a/backend/command-api/src/services/db.js b/backend/command-api/src/services/db.js index 5499a01e..3fdfbba7 100644 --- a/backend/command-api/src/services/db.js +++ b/backend/command-api/src/services/db.js @@ -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; diff --git a/backend/command-api/src/services/mobile-command-service.js b/backend/command-api/src/services/mobile-command-service.js new file mode 100644 index 00000000..09ffec2f --- /dev/null +++ b/backend/command-api/src/services/mobile-command-service.js @@ -0,0 +1,2348 @@ +import crypto from 'node:crypto'; +import { AppError } from '../lib/errors.js'; +import { query, withTransaction } from './db.js'; +import { loadActorContext, requireClientContext, requireStaffContext } from './actor-context.js'; +import { + cancelOrder as cancelOrderCommand, + clockIn as clockInCommand, + clockOut as clockOutCommand, + createOrder as createOrderCommand, +} from './command-service.js'; + +function toIsoOrNull(value) { + return value ? new Date(value).toISOString() : null; +} + +function normalizeSlug(input) { + return `${input || ''}` + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 60); +} + +function normalizePhone(value) { + if (!value) return null; + return `${value}`.trim(); +} + +function ensureArray(value) { + return Array.isArray(value) ? value : []; +} + +function generateOrderNumber(prefix = 'ORD') { + const stamp = Date.now().toString().slice(-8); + const random = crypto.randomInt(100, 999); + return `${prefix}-${stamp}${random}`; +} + +function normalizeWorkerCount(position) { + return position.workerCount ?? position.workersNeeded ?? 1; +} + +function roleCodeFromName(name) { + return `${name || 'role'}` + .toUpperCase() + .replace(/[^A-Z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, 50) || 'ROLE'; +} + +function toArrayOfUniqueIntegers(values, fallback) { + const source = Array.isArray(values) && values.length > 0 ? values : fallback; + return [...new Set(source.map((value) => Number(value)).filter((value) => Number.isInteger(value) && value >= 0 && value <= 6))]; +} + +function combineDateAndTime(dateValue, timeValue) { + const [hours, minutes] = `${timeValue}`.split(':').map((value) => Number.parseInt(value, 10)); + const date = new Date(`${dateValue}T00:00:00.000Z`); + if (Number.isNaN(date.getTime()) || Number.isNaN(hours) || Number.isNaN(minutes)) { + throw new AppError('VALIDATION_ERROR', 'Invalid date/time combination for order schedule', 400, { + date: dateValue, + time: timeValue, + }); + } + date.setUTCHours(hours, minutes, 0, 0); + return date; +} + +function buildShiftWindow(dateValue, startTime, endTime) { + const startsAt = combineDateAndTime(dateValue, startTime); + const endsAt = combineDateAndTime(dateValue, endTime); + if (endsAt <= startsAt) { + endsAt.setUTCDate(endsAt.getUTCDate() + 1); + } + return { + startsAt: startsAt.toISOString(), + endsAt: endsAt.toISOString(), + }; +} + +function materializeScheduleDates({ orderType, orderDate, startDate, endDate, recurrenceDays, daysOfWeek, horizonDays }) { + if (orderType === 'ONE_TIME') { + return [orderDate]; + } + + const from = new Date(`${startDate}T00:00:00.000Z`); + if (Number.isNaN(from.getTime())) { + throw new AppError('VALIDATION_ERROR', 'Invalid startDate', 400, { startDate }); + } + + const to = orderType === 'RECURRING' + ? new Date(`${endDate}T00:00:00.000Z`) + : (() => { + if (endDate) return new Date(`${endDate}T00:00:00.000Z`); + const fallback = new Date(from); + fallback.setUTCDate(fallback.getUTCDate() + (horizonDays || 28)); + return fallback; + })(); + + if (Number.isNaN(to.getTime()) || to < from) { + throw new AppError('VALIDATION_ERROR', 'Invalid scheduling window', 400, { + startDate, + endDate: endDate || null, + }); + } + + const activeDays = orderType === 'RECURRING' + ? toArrayOfUniqueIntegers(recurrenceDays, []) + : toArrayOfUniqueIntegers(daysOfWeek, [1, 2, 3, 4, 5]); + + const dates = []; + const cursor = new Date(from); + while (cursor <= to) { + if (activeDays.includes(cursor.getUTCDay())) { + dates.push(cursor.toISOString().slice(0, 10)); + } + cursor.setUTCDate(cursor.getUTCDate() + 1); + } + + if (dates.length === 0) { + throw new AppError('VALIDATION_ERROR', 'Schedule did not produce any shifts', 400, { + orderType, + startDate, + endDate: endDate || null, + activeDays, + }); + } + + return dates; +} + +async function loadHubDetails(tenantId, businessId, hubId) { + const result = await query( + ` + SELECT + cp.id, + cp.label, + cp.address, + cp.latitude, + cp.longitude, + cp.geofence_radius_meters, + cp.metadata + FROM clock_points cp + WHERE cp.tenant_id = $1 + AND cp.business_id = $2 + AND cp.id = $3 + AND cp.status = 'ACTIVE' + `, + [tenantId, businessId, hubId] + ); + + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Hub not found in business scope', 404, { hubId }); + } + + return result.rows[0]; +} + +async function resolveRoleCatalogEntries(tenantId, positions) { + const roleIds = positions.map((position) => position.roleId).filter(Boolean); + const roleCodes = positions.map((position) => position.roleCode).filter(Boolean); + if (roleIds.length === 0 && roleCodes.length === 0) { + return new Map(); + } + + const result = await query( + ` + SELECT id, code, name + FROM roles_catalog + WHERE tenant_id = $1 + AND status = 'ACTIVE' + AND ( + (cardinality($2::uuid[]) > 0 AND id = ANY($2::uuid[])) + OR + (cardinality($3::text[]) > 0 AND code = ANY($3::text[])) + ) + `, + [tenantId, roleIds, roleCodes] + ); + + const lookup = new Map(); + for (const row of result.rows) { + lookup.set(row.id, row); + lookup.set(row.code, row); + } + return lookup; +} + +function buildShiftRoleEntry(position, roleLookup) { + const role = roleLookup.get(position.roleId) || roleLookup.get(position.roleCode); + const workersNeeded = normalizeWorkerCount(position); + const billRateCents = position.billRateCents ?? position.hourlyRateCents ?? 0; + const payRateCents = position.payRateCents ?? position.hourlyRateCents ?? 0; + + return { + startTime: position.startTime, + endTime: position.endTime, + roleId: role?.id || position.roleId || null, + roleCode: role?.code || position.roleCode || roleCodeFromName(position.roleName), + roleName: role?.name || position.roleName || role?.name || 'Role', + workersNeeded, + payRateCents, + billRateCents, + metadata: { + lunchBreakMinutes: position.lunchBreakMinutes ?? 0, + paidBreak: position.paidBreak ?? false, + instantBook: position.instantBook ?? false, + ...position.metadata, + }, + }; +} + +function buildOrderShifts({ dates, positions, timezone, hub }) { + const shifts = []; + + for (const dateValue of dates) { + const buckets = new Map(); + for (const position of positions) { + const key = `${position.startTime}|${position.endTime}`; + const bucket = buckets.get(key) || []; + bucket.push(position); + buckets.set(key, bucket); + } + + let shiftIndex = 0; + for (const [timeKey, bucketPositions] of buckets.entries()) { + shiftIndex += 1; + const [startTime, endTime] = timeKey.split('|'); + const window = buildShiftWindow(dateValue, startTime, endTime); + const requiredWorkers = bucketPositions.reduce((sum, position) => sum + normalizeWorkerCount(position), 0); + shifts.push({ + shiftCode: `SFT-${dateValue.replaceAll('-', '')}-${shiftIndex}`, + title: `${hub.label} ${startTime}-${endTime}`, + startsAt: window.startsAt, + endsAt: window.endsAt, + timezone: timezone || 'UTC', + clockPointId: hub.id, + locationName: hub.label, + locationAddress: hub.address || null, + latitude: hub.latitude == null ? undefined : Number(hub.latitude), + longitude: hub.longitude == null ? undefined : Number(hub.longitude), + geofenceRadiusMeters: hub.geofence_radius_meters || undefined, + requiredWorkers, + metadata: { + city: hub.metadata?.city || null, + state: hub.metadata?.state || null, + zipCode: hub.metadata?.zipCode || null, + }, + roles: bucketPositions, + }); + } + } + + return shifts; +} + +function buildMobileOrderMetadata(orderType, payload, extra = {}) { + return { + orderType, + source: 'mobile-api', + recurrenceDays: payload.recurrenceDays || payload.daysOfWeek || null, + startDate: payload.startDate || payload.orderDate || null, + endDate: payload.endDate || null, + ...payload.metadata, + ...extra, + }; +} + +function inferAttendanceSourceType(payload) { + if (payload.sourceType) return payload.sourceType; + if (payload.nfcTagId) return 'NFC'; + if (payload.latitude != null && payload.longitude != null) return 'GEO'; + return 'MANUAL'; +} + +async function buildOrderCreatePayloadFromMobile(actor, context, payload, orderType, extra = {}) { + const hubId = payload.hubId || extra.hubId; + const hub = await loadHubDetails(context.tenant.tenantId, context.business.businessId, hubId); + const positionInputs = payload.positions || extra.positions || []; + const roleLookup = await resolveRoleCatalogEntries(context.tenant.tenantId, positionInputs); + const normalizedPositions = positionInputs.map((position) => buildShiftRoleEntry(position, roleLookup)); + const dates = materializeScheduleDates({ + orderType, + orderDate: payload.orderDate || extra.orderDate, + startDate: payload.startDate || extra.startDate, + endDate: payload.endDate || extra.endDate, + recurrenceDays: payload.recurrenceDays || extra.recurrenceDays, + daysOfWeek: payload.daysOfWeek || extra.daysOfWeek, + horizonDays: payload.horizonDays || extra.horizonDays, + }); + const shifts = buildOrderShifts({ + dates, + positions: normalizedPositions, + timezone: payload.timezone || extra.timezone, + hub, + }); + const startsAt = shifts[0]?.startsAt || null; + const endsAt = shifts[shifts.length - 1]?.endsAt || null; + return { + tenantId: context.tenant.tenantId, + businessId: context.business.businessId, + vendorId: payload.vendorId ?? extra.vendorId ?? null, + orderNumber: generateOrderNumber(orderType === 'ONE_TIME' ? 'ORD' : orderType === 'RECURRING' ? 'RCR' : 'PRM'), + title: payload.eventName || extra.eventName || 'Untitled Order', + description: payload.description ?? extra.description ?? null, + status: 'OPEN', + serviceType: payload.serviceType ?? extra.serviceType ?? 'EVENT', + startsAt, + endsAt, + locationName: hub.label, + locationAddress: hub.address || null, + latitude: hub.latitude == null ? undefined : Number(hub.latitude), + longitude: hub.longitude == null ? undefined : Number(hub.longitude), + notes: payload.notes ?? extra.notes ?? null, + metadata: buildMobileOrderMetadata(orderType, payload, extra.metadata || {}), + shifts: shifts.map((shift, index) => ({ + ...shift, + shiftCode: shift.shiftCode || `SFT-${index + 1}`, + roles: shift.roles.map((role) => ({ + roleId: role.roleId, + roleCode: role.roleCode, + roleName: role.roleName, + workersNeeded: role.workersNeeded, + payRateCents: role.payRateCents, + billRateCents: role.billRateCents, + metadata: role.metadata, + })), + })), + }; +} + +async function loadEditableOrderTemplate(actorUid, tenantId, businessId, orderId) { + const context = await requireClientContext(actorUid); + if (context.tenant.tenantId !== tenantId || context.business.businessId !== businessId) { + throw new AppError('FORBIDDEN', 'Order is outside the current client context', 403, { orderId }); + } + + const result = await query( + ` + SELECT + o.id AS "orderId", + o.title AS "eventName", + o.description, + o.notes, + o.vendor_id AS "vendorId", + o.service_type AS "serviceType", + o.metadata, + COALESCE( + json_agg( + json_build_object( + 'shiftId', s.id, + 'clockPointId', s.clock_point_id, + 'date', to_char(s.starts_at AT TIME ZONE 'UTC', 'YYYY-MM-DD'), + 'startTime', to_char(s.starts_at AT TIME ZONE 'UTC', 'HH24:MI'), + 'endTime', to_char(s.ends_at AT TIME ZONE 'UTC', 'HH24:MI'), + 'roles', ( + SELECT json_agg( + json_build_object( + 'roleId', sr.role_id, + 'roleCode', sr.role_code, + 'roleName', sr.role_name, + 'workerCount', sr.workers_needed, + 'hourlyRateCents', sr.bill_rate_cents, + 'payRateCents', sr.pay_rate_cents, + 'billRateCents', sr.bill_rate_cents, + 'startTime', to_char(s.starts_at AT TIME ZONE 'UTC', 'HH24:MI'), + 'endTime', to_char(s.ends_at AT TIME ZONE 'UTC', 'HH24:MI') + ) + ORDER BY sr.role_name ASC + ) + FROM shift_roles sr + WHERE sr.shift_id = s.id + ) + ) + ORDER BY s.starts_at ASC + ), + '[]'::json + ) AS shifts + FROM orders o + JOIN shifts s ON s.order_id = o.id + WHERE o.id = $1 + AND o.tenant_id = $2 + AND o.business_id = $3 + GROUP BY o.id + `, + [orderId, tenantId, businessId] + ); + + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Order not found for edit flow', 404, { orderId }); + } + + return result.rows[0]; +} + +async function resolveStaffAssignmentForClock(actorUid, tenantId, payload, { requireOpenSession = false } = {}) { + const context = await requireStaffContext(actorUid); + if (payload.assignmentId) { + return { assignmentId: payload.assignmentId, context }; + } + + if (payload.applicationId) { + const fromApplication = await query( + ` + SELECT a.id AS "assignmentId" + FROM assignments a + JOIN applications app ON app.id = a.application_id + JOIN staffs s ON s.id = a.staff_id + WHERE a.tenant_id = $1 + AND app.id = $2 + AND s.user_id = $3 + ORDER BY a.created_at DESC + LIMIT 1 + `, + [tenantId, payload.applicationId, actorUid] + ); + if (fromApplication.rowCount > 0) { + return { assignmentId: fromApplication.rows[0].assignmentId, context }; + } + } + + if (payload.shiftId) { + const fromShift = await query( + ` + SELECT a.id AS "assignmentId" + FROM assignments a + JOIN staffs s ON s.id = a.staff_id + WHERE a.tenant_id = $1 + AND a.shift_id = $2 + AND s.user_id = $3 + ORDER BY a.created_at DESC + LIMIT 1 + `, + [tenantId, payload.shiftId, actorUid] + ); + if (fromShift.rowCount > 0) { + return { assignmentId: fromShift.rows[0].assignmentId, context }; + } + } + + if (requireOpenSession) { + const openSession = await query( + ` + SELECT attendance_sessions.assignment_id AS "assignmentId" + FROM attendance_sessions + JOIN staffs s ON s.id = attendance_sessions.staff_id + WHERE attendance_sessions.tenant_id = $1 + AND s.user_id = $2 + AND attendance_sessions.status = 'OPEN' + ORDER BY attendance_sessions.updated_at DESC + LIMIT 1 + `, + [tenantId, actorUid] + ); + if (openSession.rowCount > 0) { + return { assignmentId: openSession.rows[0].assignmentId, context }; + } + } + + throw new AppError('NOT_FOUND', 'No assignment found for the current staff clock action', 404, payload); +} + +async function ensureActorUser(client, actor, fields = {}) { + await client.query( + ` + INSERT INTO users (id, email, display_name, phone, status, metadata) + VALUES ($1, $2, $3, $4, 'ACTIVE', COALESCE($5::jsonb, '{}'::jsonb)) + ON CONFLICT (id) DO UPDATE + SET email = COALESCE(EXCLUDED.email, users.email), + display_name = COALESCE(EXCLUDED.display_name, users.display_name), + phone = COALESCE(EXCLUDED.phone, users.phone), + metadata = COALESCE(users.metadata, '{}'::jsonb) || COALESCE(EXCLUDED.metadata, '{}'::jsonb), + updated_at = NOW() + `, + [ + actor.uid, + fields.email ?? actor.email ?? null, + fields.displayName ?? actor.email ?? null, + fields.phone ?? null, + JSON.stringify(fields.metadata || {}), + ] + ); +} + +async function insertDomainEvent(client, { + tenantId, + aggregateType, + aggregateId, + eventType, + actorUserId, + payload, +}) { + await client.query( + ` + INSERT INTO domain_events ( + tenant_id, + aggregate_type, + aggregate_id, + sequence, + event_type, + actor_user_id, + payload + ) + SELECT + $1, + $2, + $3, + COALESCE(MAX(sequence) + 1, 1), + $4, + $5, + $6::jsonb + FROM domain_events + WHERE tenant_id = $1 + AND aggregate_type = $2 + AND aggregate_id = $3 + `, + [tenantId, aggregateType, aggregateId, eventType, actorUserId, JSON.stringify(payload || {})] + ); +} + +async function requireBusinessMembership(client, businessId, userId) { + const result = await client.query( + ` + SELECT bm.id, bm.tenant_id, bm.business_id, bm.user_id + FROM business_memberships bm + WHERE bm.business_id = $1 + AND bm.user_id = $2 + AND bm.membership_status = 'ACTIVE' + `, + [businessId, userId] + ); + if (result.rowCount === 0) { + throw new AppError('FORBIDDEN', 'Business membership not found for current user', 403, { + businessId, + userId, + }); + } + return result.rows[0]; +} + +async function requireClockPoint(client, tenantId, businessId, hubId, { forUpdate = false } = {}) { + const result = await client.query( + ` + SELECT id, tenant_id, business_id, label, status, cost_center_id, nfc_tag_uid, metadata + FROM clock_points + WHERE id = $1 + AND tenant_id = $2 + AND business_id = $3 + ${forUpdate ? 'FOR UPDATE' : ''} + `, + [hubId, tenantId, businessId] + ); + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Hub not found in business scope', 404, { + tenantId, + businessId, + hubId, + }); + } + return result.rows[0]; +} + +async function requireInvoice(client, tenantId, businessId, invoiceId) { + const result = await client.query( + ` + SELECT id, tenant_id, business_id, status, metadata + FROM invoices + WHERE id = $1 + AND tenant_id = $2 + AND business_id = $3 + FOR UPDATE + `, + [invoiceId, tenantId, businessId] + ); + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Invoice not found in business scope', 404, { + tenantId, + businessId, + invoiceId, + }); + } + return result.rows[0]; +} + +async function requireStaffByActor(client, tenantId, actorUid) { + const result = await client.query( + ` + SELECT s.id, s.tenant_id, s.user_id, s.full_name, s.email, s.phone, s.metadata, s.primary_role, + w.id AS workforce_id, w.vendor_id, w.workforce_number + FROM staffs s + LEFT JOIN workforce w ON w.staff_id = s.id + WHERE s.tenant_id = $1 + AND s.user_id = $2 + ORDER BY s.created_at ASC + LIMIT 1 + FOR UPDATE OF s + `, + [tenantId, actorUid] + ); + if (result.rowCount === 0) { + throw new AppError('FORBIDDEN', 'Staff profile not found for current user', 403, { + tenantId, + actorUid, + }); + } + return result.rows[0]; +} + +async function requireShiftRoleForStaffApply(client, tenantId, shiftId, roleId, staffId) { + const result = await client.query( + ` + SELECT + s.id AS shift_id, + s.tenant_id, + s.business_id, + s.vendor_id, + s.status AS shift_status, + s.starts_at, + s.ends_at, + sr.id AS shift_role_id, + sr.role_code, + sr.role_name, + sr.workers_needed, + sr.assigned_count, + sr.pay_rate_cents + FROM shifts s + JOIN shift_roles sr ON sr.shift_id = s.id + WHERE s.id = $1 + AND s.tenant_id = $2 + AND ($3::uuid IS NULL OR sr.id = $3) + AND s.status IN ('OPEN', 'PENDING_CONFIRMATION', 'ASSIGNED') + AND NOT EXISTS ( + SELECT 1 + FROM applications a + WHERE a.shift_role_id = sr.id + AND a.staff_id = $4 + AND a.status IN ('PENDING', 'CONFIRMED', 'CHECKED_IN', 'COMPLETED') + ) + ORDER BY sr.created_at ASC + LIMIT 1 + FOR UPDATE OF s, sr + `, + [shiftId, tenantId, roleId || null, staffId] + ); + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Open shift role not found or already applied', 404, { + tenantId, + shiftId, + roleId: roleId || null, + }); + } + return result.rows[0]; +} + +async function requirePendingAssignmentForActor(client, tenantId, shiftId, actorUid) { + const result = await client.query( + ` + SELECT + a.id, + a.tenant_id, + a.business_id, + a.vendor_id, + a.shift_id, + a.shift_role_id, + a.workforce_id, + a.staff_id, + a.status, + a.metadata + FROM assignments a + JOIN staffs s ON s.id = a.staff_id + WHERE a.tenant_id = $1 + AND a.shift_id = $2 + AND s.user_id = $3 + AND a.status = 'ASSIGNED' + ORDER BY a.created_at ASC + LIMIT 1 + FOR UPDATE OF a + `, + [tenantId, shiftId, actorUid] + ); + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Pending shift assignment not found for current user', 404, { + tenantId, + shiftId, + actorUid, + }); + } + return result.rows[0]; +} + +async function requireAnyAssignmentForActor(client, tenantId, shiftId, actorUid) { + const result = await client.query( + ` + SELECT + a.id, + a.tenant_id, + a.business_id, + a.vendor_id, + a.shift_id, + a.shift_role_id, + a.workforce_id, + a.staff_id, + a.status, + a.metadata + FROM assignments a + JOIN staffs s ON s.id = a.staff_id + WHERE a.tenant_id = $1 + AND a.shift_id = $2 + AND s.user_id = $3 + ORDER BY a.created_at ASC + LIMIT 1 + FOR UPDATE OF a + `, + [tenantId, shiftId, actorUid] + ); + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Shift assignment not found for current user', 404, { + tenantId, + shiftId, + actorUid, + }); + } + return result.rows[0]; +} + +async function refreshShiftRoleCounts(client, shiftRoleId) { + await client.query( + ` + UPDATE shift_roles sr + SET assigned_count = counts.assigned_count, + updated_at = NOW() + FROM ( + SELECT $1::uuid AS shift_role_id, + COUNT(*) FILTER ( + WHERE status IN ('ASSIGNED', 'ACCEPTED', 'SWAP_REQUESTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED') + )::INTEGER AS assigned_count + FROM assignments + WHERE shift_role_id = $1 + ) counts + WHERE sr.id = counts.shift_role_id + `, + [shiftRoleId] + ); +} + +async function refreshShiftCounts(client, shiftId) { + await client.query( + ` + UPDATE shifts s + SET assigned_workers = counts.assigned_workers, + updated_at = NOW() + FROM ( + SELECT $1::uuid AS shift_id, + COUNT(*) FILTER ( + WHERE status IN ('ASSIGNED', 'ACCEPTED', 'SWAP_REQUESTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED') + )::INTEGER AS assigned_workers + FROM assignments + WHERE shift_id = $1 + ) counts + WHERE s.id = counts.shift_id + `, + [shiftId] + ); +} + +async function resolveStaffOnboardingScope(client, actorUid, tenantId, vendorId) { + const existing = await loadActorContext(actorUid); + if (existing.tenant?.tenantId && existing.vendor?.vendorId) { + return { + tenantId: existing.tenant.tenantId, + vendorId: existing.vendor.vendorId, + }; + } + + if (tenantId && vendorId) { + const verify = await client.query( + ` + SELECT t.id AS tenant_id, v.id AS vendor_id + FROM tenants t + JOIN vendors v ON v.tenant_id = t.id + WHERE t.id = $1 + AND v.id = $2 + AND t.status = 'ACTIVE' + AND v.status = 'ACTIVE' + `, + [tenantId, vendorId] + ); + if (verify.rowCount === 0) { + throw new AppError('VALIDATION_ERROR', 'tenantId and vendorId do not resolve to an active onboarding scope', 400, { + tenantId, + vendorId, + }); + } + return { + tenantId, + vendorId, + }; + } + + const fallback = await client.query( + ` + SELECT t.id AS tenant_id, v.id AS vendor_id + FROM tenants t + JOIN vendors v ON v.tenant_id = t.id + WHERE t.status = 'ACTIVE' + AND v.status = 'ACTIVE' + ORDER BY t.created_at ASC, v.created_at ASC + LIMIT 1 + ` + ); + if (fallback.rowCount === 0) { + throw new AppError('CONFIGURATION_ERROR', 'No active tenant/vendor onboarding scope is configured', 500); + } + + return { + tenantId: fallback.rows[0].tenant_id, + vendorId: fallback.rows[0].vendor_id, + }; +} + +function buildStaffMetadataPatch(existing, payload) { + return { + ...existing, + ...(payload.bio !== undefined ? { bio: payload.bio } : {}), + ...(payload.firstName !== undefined ? { firstName: payload.firstName } : {}), + ...(payload.lastName !== undefined ? { lastName: payload.lastName } : {}), + ...(payload.preferredLocations !== undefined ? { preferredLocations: payload.preferredLocations } : {}), + ...(payload.maxDistanceMiles !== undefined ? { maxDistanceMiles: payload.maxDistanceMiles } : {}), + ...(payload.industries !== undefined ? { industries: payload.industries } : {}), + ...(payload.skills !== undefined ? { skills: payload.skills } : {}), + ...(payload.profileVisible !== undefined ? { profileVisible: payload.profileVisible } : {}), + }; +} + +export async function createHub(actor, payload) { + const context = await requireClientContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const businessMembership = await requireBusinessMembership(client, context.business.businessId, actor.uid); + + let costCenterId = payload.costCenterId || null; + if (costCenterId) { + const costCenter = await client.query( + ` + SELECT id + FROM cost_centers + WHERE id = $1 + AND tenant_id = $2 + AND business_id = $3 + `, + [costCenterId, context.tenant.tenantId, context.business.businessId] + ); + if (costCenter.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Cost center not found in business scope', 404, { + costCenterId, + }); + } + } + + const result = await client.query( + ` + INSERT INTO clock_points ( + tenant_id, + business_id, + label, + address, + latitude, + longitude, + geofence_radius_meters, + nfc_tag_uid, + cost_center_id, + status, + metadata + ) + VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, 120), $8, $9, 'ACTIVE', $10::jsonb) + RETURNING id + `, + [ + context.tenant.tenantId, + context.business.businessId, + payload.name, + payload.fullAddress || null, + payload.latitude ?? null, + payload.longitude ?? null, + payload.geofenceRadiusMeters ?? null, + payload.nfcTagId || null, + costCenterId, + JSON.stringify({ + placeId: payload.placeId || null, + street: payload.street || null, + city: payload.city || null, + state: payload.state || null, + country: payload.country || null, + zipCode: payload.zipCode || null, + createdByMembershipId: businessMembership.id, + }), + ] + ); + + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'clock_point', + aggregateId: result.rows[0].id, + eventType: 'HUB_CREATED', + actorUserId: actor.uid, + payload, + }); + + return { hubId: result.rows[0].id, created: true }; + }); +} + +export async function updateHub(actor, payload) { + const context = await requireClientContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const hub = await requireClockPoint(client, context.tenant.tenantId, context.business.businessId, payload.hubId, { forUpdate: true }); + + let costCenterId = payload.costCenterId; + if (costCenterId) { + const costCenter = await client.query( + ` + SELECT id + FROM cost_centers + WHERE id = $1 + AND tenant_id = $2 + AND business_id = $3 + `, + [costCenterId, context.tenant.tenantId, context.business.businessId] + ); + if (costCenter.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Cost center not found in business scope', 404, { + costCenterId, + }); + } + } + + const nextMetadata = { + ...(hub.metadata || {}), + ...(payload.placeId !== undefined ? { placeId: payload.placeId } : {}), + ...(payload.street !== undefined ? { street: payload.street } : {}), + ...(payload.city !== undefined ? { city: payload.city } : {}), + ...(payload.state !== undefined ? { state: payload.state } : {}), + ...(payload.country !== undefined ? { country: payload.country } : {}), + ...(payload.zipCode !== undefined ? { zipCode: payload.zipCode } : {}), + }; + + await client.query( + ` + UPDATE clock_points + SET label = COALESCE($2, label), + address = COALESCE($3, address), + latitude = COALESCE($4, latitude), + longitude = COALESCE($5, longitude), + geofence_radius_meters = COALESCE($6, geofence_radius_meters), + cost_center_id = COALESCE($7, cost_center_id), + metadata = $8::jsonb, + updated_at = NOW() + WHERE id = $1 + `, + [ + hub.id, + payload.name || null, + payload.fullAddress || null, + payload.latitude ?? null, + payload.longitude ?? null, + payload.geofenceRadiusMeters ?? null, + costCenterId || null, + JSON.stringify(nextMetadata), + ] + ); + + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'clock_point', + aggregateId: hub.id, + eventType: 'HUB_UPDATED', + actorUserId: actor.uid, + payload, + }); + + return { hubId: hub.id, updated: true }; + }); +} + +export async function deleteHub(actor, payload) { + const context = await requireClientContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const hub = await requireClockPoint(client, context.tenant.tenantId, context.business.businessId, payload.hubId, { forUpdate: true }); + const activeOrders = await client.query( + ` + SELECT 1 + FROM shifts + WHERE clock_point_id = $1 + AND status IN ('DRAFT', 'OPEN', 'PENDING_CONFIRMATION', 'ASSIGNED', 'ACTIVE') + LIMIT 1 + `, + [hub.id] + ); + if (activeOrders.rowCount > 0) { + throw new AppError('HUB_DELETE_BLOCKED', 'Cannot delete a hub with active orders or shifts', 409, { + hubId: hub.id, + }); + } + + await client.query( + ` + UPDATE clock_points + SET status = 'INACTIVE', + updated_at = NOW() + WHERE id = $1 + `, + [hub.id] + ); + + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'clock_point', + aggregateId: hub.id, + eventType: 'HUB_ARCHIVED', + actorUserId: actor.uid, + payload: { reason: payload.reason || null }, + }); + + return { hubId: hub.id, deleted: true }; + }); +} + +export async function assignHubNfc(actor, payload) { + const context = await requireClientContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const hub = await requireClockPoint(client, context.tenant.tenantId, context.business.businessId, payload.hubId, { forUpdate: true }); + await client.query( + ` + UPDATE clock_points + SET nfc_tag_uid = $2, + updated_at = NOW() + WHERE id = $1 + `, + [hub.id, payload.nfcTagId] + ); + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'clock_point', + aggregateId: hub.id, + eventType: 'HUB_NFC_ASSIGNED', + actorUserId: actor.uid, + payload, + }); + return { hubId: hub.id, nfcTagId: payload.nfcTagId }; + }); +} + +export async function assignHubManager(actor, payload) { + const context = await requireClientContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const hub = await requireClockPoint(client, context.tenant.tenantId, context.business.businessId, payload.hubId, { forUpdate: true }); + let businessMembershipId = payload.businessMembershipId || null; + if (!businessMembershipId) { + const membership = await client.query( + ` + SELECT id + FROM business_memberships + WHERE tenant_id = $1 + AND business_id = $2 + AND user_id = $3 + AND membership_status = 'ACTIVE' + `, + [context.tenant.tenantId, context.business.businessId, payload.managerUserId] + ); + if (membership.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Business team member not found for hub manager assignment', 404, { + managerUserId: payload.managerUserId, + }); + } + businessMembershipId = membership.rows[0].id; + } else { + const membership = await client.query( + ` + SELECT id + FROM business_memberships + WHERE id = $1 + AND tenant_id = $2 + AND business_id = $3 + AND membership_status = 'ACTIVE' + `, + [businessMembershipId, context.tenant.tenantId, context.business.businessId] + ); + if (membership.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Business membership not found for hub manager assignment', 404, { + businessMembershipId, + }); + } + } + + const result = await client.query( + ` + INSERT INTO hub_managers (tenant_id, hub_id, business_membership_id) + VALUES ($1, $2, $3) + ON CONFLICT (hub_id, business_membership_id) DO UPDATE + SET updated_at = NOW() + RETURNING id + `, + [context.tenant.tenantId, hub.id, businessMembershipId] + ); + + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'clock_point', + aggregateId: hub.id, + eventType: 'HUB_MANAGER_ASSIGNED', + actorUserId: actor.uid, + payload: { + businessMembershipId, + }, + }); + + return { managerAssignmentId: result.rows[0].id, hubId: hub.id, businessMembershipId }; + }); +} + +export async function approveInvoice(actor, payload) { + const context = await requireClientContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const invoice = await requireInvoice(client, context.tenant.tenantId, context.business.businessId, payload.invoiceId); + await client.query( + ` + UPDATE invoices + SET status = 'APPROVED', + metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb, + updated_at = NOW() + WHERE id = $1 + `, + [invoice.id, JSON.stringify({ + approvedBy: actor.uid, + approvedAt: new Date().toISOString(), + })] + ); + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'invoice', + aggregateId: invoice.id, + eventType: 'INVOICE_APPROVED', + actorUserId: actor.uid, + payload: { invoiceId: invoice.id }, + }); + return { invoiceId: invoice.id, status: 'APPROVED' }; + }); +} + +export async function disputeInvoice(actor, payload) { + const context = await requireClientContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const invoice = await requireInvoice(client, context.tenant.tenantId, context.business.businessId, payload.invoiceId); + await client.query( + ` + UPDATE invoices + SET status = 'DISPUTED', + metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb, + updated_at = NOW() + WHERE id = $1 + `, + [invoice.id, JSON.stringify({ + disputedBy: actor.uid, + disputedAt: new Date().toISOString(), + disputeReason: payload.reason, + })] + ); + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'invoice', + aggregateId: invoice.id, + eventType: 'INVOICE_DISPUTED', + actorUserId: actor.uid, + payload: { invoiceId: invoice.id, reason: payload.reason }, + }); + return { invoiceId: invoice.id, status: 'DISPUTED', reason: payload.reason }; + }); +} + +export async function rateWorkerFromCoverage(actor, payload) { + const context = await requireClientContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const assignmentResult = await client.query( + ` + SELECT a.id, a.tenant_id, a.business_id, a.staff_id + FROM assignments a + WHERE a.tenant_id = $1 + AND a.business_id = $2 + AND a.staff_id = $3 + AND ($4::uuid IS NULL OR a.id = $4) + ORDER BY a.updated_at DESC + LIMIT 1 + FOR UPDATE OF a + `, + [context.tenant.tenantId, context.business.businessId, payload.staffId, payload.assignmentId || null] + ); + if (assignmentResult.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Assignment not found for worker review in business scope', 404, payload); + } + const assignment = assignmentResult.rows[0]; + + const reviewResult = await client.query( + ` + INSERT INTO staff_reviews ( + tenant_id, + business_id, + staff_id, + assignment_id, + reviewer_user_id, + rating, + review_text, + tags + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb) + ON CONFLICT (business_id, assignment_id, staff_id) DO UPDATE + SET reviewer_user_id = EXCLUDED.reviewer_user_id, + rating = EXCLUDED.rating, + review_text = EXCLUDED.review_text, + tags = EXCLUDED.tags, + updated_at = NOW() + RETURNING id + `, + [ + context.tenant.tenantId, + context.business.businessId, + payload.staffId, + assignment.id, + actor.uid, + payload.rating, + payload.feedback || null, + JSON.stringify(ensureArray(payload.issueFlags || [])), + ] + ); + + if (payload.markAsFavorite === true) { + await client.query( + ` + INSERT INTO staff_favorites (tenant_id, business_id, staff_id, created_by_user_id) + VALUES ($1, $2, $3, $4) + ON CONFLICT (business_id, staff_id) DO UPDATE + SET created_by_user_id = EXCLUDED.created_by_user_id + `, + [context.tenant.tenantId, context.business.businessId, payload.staffId, actor.uid] + ); + } + + if (payload.markAsFavorite === false) { + await client.query( + ` + DELETE FROM staff_favorites + WHERE tenant_id = $1 + AND business_id = $2 + AND staff_id = $3 + `, + [context.tenant.tenantId, context.business.businessId, payload.staffId] + ); + } + + await client.query( + ` + UPDATE staffs + SET average_rating = review_stats.avg_rating, + rating_count = review_stats.rating_count, + updated_at = NOW() + FROM ( + SELECT staff_id, + ROUND(AVG(rating)::numeric, 2) AS avg_rating, + COUNT(*)::INTEGER AS rating_count + FROM staff_reviews + WHERE staff_id = $1 + GROUP BY staff_id + ) review_stats + WHERE staffs.id = review_stats.staff_id + `, + [payload.staffId] + ); + + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'staff_review', + aggregateId: reviewResult.rows[0].id, + eventType: 'STAFF_REVIEWED_FROM_COVERAGE', + actorUserId: actor.uid, + payload, + }); + + return { + reviewId: reviewResult.rows[0].id, + assignmentId: assignment.id, + staffId: payload.staffId, + rating: payload.rating, + markAsFavorite: payload.markAsFavorite ?? null, + issueFlags: ensureArray(payload.issueFlags || []), + feedback: payload.feedback || null, + }; + }); +} + +export async function cancelLateWorker(actor, payload) { + const context = await requireClientContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const result = await client.query( + ` + SELECT a.id, a.shift_id, a.shift_role_id, a.staff_id, a.status, s.required_workers, s.assigned_workers, s.tenant_id + FROM assignments a + JOIN shifts s ON s.id = a.shift_id + WHERE a.id = $1 + AND a.tenant_id = $2 + AND a.business_id = $3 + FOR UPDATE OF a, s + `, + [payload.assignmentId, context.tenant.tenantId, context.business.businessId] + ); + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Late worker assignment not found in business scope', 404, { + assignmentId: payload.assignmentId, + }); + } + const assignment = result.rows[0]; + await client.query( + ` + UPDATE assignments + SET status = 'CANCELLED', + metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb, + updated_at = NOW() + WHERE id = $1 + `, + [assignment.id, JSON.stringify({ + cancellationReason: payload.reason || 'Cancelled for lateness', + cancelledBy: actor.uid, + cancelledAt: new Date().toISOString(), + })] + ); + await refreshShiftRoleCounts(client, assignment.shift_role_id); + await refreshShiftCounts(client, assignment.shift_id); + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'assignment', + aggregateId: assignment.id, + eventType: 'LATE_WORKER_CANCELLED', + actorUserId: actor.uid, + payload, + }); + return { + assignmentId: assignment.id, + shiftId: assignment.shift_id, + replacementSearchTriggered: true, + status: 'CANCELLED', + }; + }); +} + +export async function createClientOneTimeOrder(actor, payload) { + const context = await requireClientContext(actor.uid); + const commandPayload = await buildOrderCreatePayloadFromMobile(actor, context, payload, 'ONE_TIME'); + return createOrderCommand(actor, commandPayload); +} + +export async function createClientRecurringOrder(actor, payload) { + const context = await requireClientContext(actor.uid); + const commandPayload = await buildOrderCreatePayloadFromMobile(actor, context, payload, 'RECURRING'); + return createOrderCommand(actor, commandPayload); +} + +export async function createClientPermanentOrder(actor, payload) { + const context = await requireClientContext(actor.uid); + const commandPayload = await buildOrderCreatePayloadFromMobile(actor, context, payload, 'PERMANENT'); + return createOrderCommand(actor, commandPayload); +} + +export async function createEditedOrderCopy(actor, payload) { + const context = await requireClientContext(actor.uid); + const template = await loadEditableOrderTemplate( + actor.uid, + context.tenant.tenantId, + context.business.businessId, + payload.orderId + ); + + 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 firstShift = templateShifts[0] || {}; + const lastShift = templateShifts[templateShifts.length - 1] || {}; + const inferredOrderType = payload.orderType || template.metadata?.orderType || 'ONE_TIME'; + const inferredDays = [...new Set(templateShifts.map((shift) => new Date(`${shift.date}T00:00:00.000Z`).getUTCDay()).filter((value) => Number.isInteger(value)))]; + + const commandPayload = await buildOrderCreatePayloadFromMobile( + actor, + context, + { + ...payload, + hubId: payload.hubId || firstShift.clockPointId, + vendorId: payload.vendorId ?? template.vendorId ?? undefined, + eventName: payload.eventName || template.eventName, + description: payload.description ?? template.description ?? undefined, + notes: payload.notes ?? template.notes ?? undefined, + serviceType: payload.serviceType ?? template.serviceType ?? undefined, + positions: payload.positions || templatePositions, + orderDate: payload.orderDate || firstShift.date, + startDate: payload.startDate || firstShift.date, + endDate: payload.endDate || lastShift.date || firstShift.date, + recurrenceDays: payload.recurrenceDays || inferredDays, + daysOfWeek: payload.daysOfWeek || inferredDays, + metadata: { + sourceOrderId: payload.orderId, + ...template.metadata, + ...payload.metadata, + }, + }, + inferredOrderType, + { + metadata: { + editSourceOrderId: payload.orderId, + }, + } + ); + + return createOrderCommand(actor, commandPayload); +} + +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, + }); +} + +export async function staffClockIn(actor, payload) { + const context = await requireStaffContext(actor.uid); + const { assignmentId } = await resolveStaffAssignmentForClock(actor.uid, context.tenant.tenantId, payload); + return clockInCommand(actor, { + assignmentId, + sourceType: inferAttendanceSourceType(payload), + sourceReference: payload.sourceReference || payload.notes || null, + nfcTagUid: payload.nfcTagId || null, + deviceId: payload.deviceId || null, + latitude: payload.latitude, + longitude: payload.longitude, + accuracyMeters: payload.accuracyMeters, + capturedAt: payload.capturedAt, + rawPayload: { + notes: payload.notes || null, + ...(payload.rawPayload || {}), + }, + }); +} + +export async function staffClockOut(actor, payload) { + const context = await requireStaffContext(actor.uid); + const { assignmentId } = await resolveStaffAssignmentForClock( + actor.uid, + context.tenant.tenantId, + payload, + { requireOpenSession: true } + ); + return clockOutCommand(actor, { + assignmentId, + sourceType: inferAttendanceSourceType(payload), + sourceReference: payload.sourceReference || payload.notes || null, + nfcTagUid: payload.nfcTagId || null, + deviceId: payload.deviceId || null, + latitude: payload.latitude, + longitude: payload.longitude, + accuracyMeters: payload.accuracyMeters, + capturedAt: payload.capturedAt, + rawPayload: { + notes: payload.notes || null, + breakMinutes: payload.breakMinutes ?? null, + applicationId: payload.applicationId || null, + ...(payload.rawPayload || {}), + }, + }); +} + +export async function updateStaffAvailabilityDay(actor, payload) { + const context = await requireStaffContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const staff = await requireStaffByActor(client, context.tenant.tenantId, actor.uid); + const result = await client.query( + ` + INSERT INTO staff_availability ( + tenant_id, + staff_id, + day_of_week, + availability_status, + time_slots, + metadata + ) + VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb) + ON CONFLICT (staff_id, day_of_week) DO UPDATE + SET availability_status = EXCLUDED.availability_status, + time_slots = EXCLUDED.time_slots, + metadata = COALESCE(staff_availability.metadata, '{}'::jsonb) || EXCLUDED.metadata, + updated_at = NOW() + RETURNING id + `, + [ + context.tenant.tenantId, + staff.id, + payload.dayOfWeek, + payload.availabilityStatus, + JSON.stringify(payload.slots || []), + JSON.stringify(payload.metadata || {}), + ] + ); + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'staff_availability', + aggregateId: result.rows[0].id, + eventType: 'STAFF_AVAILABILITY_UPDATED', + actorUserId: actor.uid, + payload, + }); + return { + availabilityId: result.rows[0].id, + dayOfWeek: payload.dayOfWeek, + availabilityStatus: payload.availabilityStatus, + slots: payload.slots || [], + }; + }); +} + +export async function quickSetStaffAvailability(actor, payload) { + const context = await requireStaffContext(actor.uid); + const presets = { + all: [0, 1, 2, 3, 4, 5, 6], + weekdays: [1, 2, 3, 4, 5], + weekends: [0, 6], + clear: [], + }; + const selectedDays = presets[payload.quickSetType]; + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const staff = await requireStaffByActor(client, context.tenant.tenantId, actor.uid); + + for (let day = 0; day <= 6; day += 1) { + const active = selectedDays.includes(day); + await client.query( + ` + INSERT INTO staff_availability ( + tenant_id, + staff_id, + day_of_week, + availability_status, + time_slots, + metadata + ) + VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb) + ON CONFLICT (staff_id, day_of_week) DO UPDATE + SET availability_status = EXCLUDED.availability_status, + time_slots = EXCLUDED.time_slots, + metadata = COALESCE(staff_availability.metadata, '{}'::jsonb) || EXCLUDED.metadata, + updated_at = NOW() + `, + [ + context.tenant.tenantId, + staff.id, + day, + active ? 'AVAILABLE' : 'UNAVAILABLE', + JSON.stringify(active ? payload.slots || [{ start: '08:00', end: '18:00' }] : []), + JSON.stringify({ + quickSetType: payload.quickSetType, + startDate: payload.startDate, + endDate: payload.endDate, + }), + ] + ); + } + + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'staff', + aggregateId: staff.id, + eventType: 'STAFF_AVAILABILITY_QUICK_SET', + actorUserId: actor.uid, + payload, + }); + return { + quickSetType: payload.quickSetType, + appliedDays: selectedDays, + }; + }); +} + +export async function applyForShift(actor, payload) { + const context = await requireStaffContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const staff = await requireStaffByActor(client, context.tenant.tenantId, actor.uid); + const shiftRole = await requireShiftRoleForStaffApply(client, context.tenant.tenantId, payload.shiftId, payload.roleId, staff.id); + const existingAssignment = await client.query( + ` + SELECT id + FROM assignments + WHERE shift_role_id = $1 + AND staff_id = $2 + AND status IN ('ASSIGNED', 'ACCEPTED', 'SWAP_REQUESTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED') + LIMIT 1 + `, + [shiftRole.shift_role_id, staff.id] + ); + if (existingAssignment.rowCount > 0) { + throw new AppError('CONFLICT', 'Staff is already assigned to this shift', 409, { + shiftId: payload.shiftId, + staffId: staff.id, + }); + } + + const instantBook = payload.instantBook === true && shiftRole.assigned_count < shiftRole.workers_needed && Boolean(staff.workforce_id); + const applicationResult = await client.query( + ` + INSERT INTO applications ( + tenant_id, + shift_id, + shift_role_id, + staff_id, + status, + origin, + metadata + ) + VALUES ($1, $2, $3, $4, $5, 'STAFF', $6::jsonb) + ON CONFLICT (shift_role_id, staff_id) DO UPDATE + SET status = EXCLUDED.status, + origin = EXCLUDED.origin, + metadata = COALESCE(applications.metadata, '{}'::jsonb) || EXCLUDED.metadata, + updated_at = NOW() + RETURNING id, status + `, + [ + context.tenant.tenantId, + shiftRole.shift_id, + shiftRole.shift_role_id, + staff.id, + instantBook ? 'CONFIRMED' : 'PENDING', + JSON.stringify({ + appliedBy: actor.uid, + instantBookRequested: payload.instantBook === true, + }), + ] + ); + + let assignmentId = null; + let assignmentStatus = null; + if (instantBook) { + const assignmentResult = await client.query( + ` + INSERT INTO assignments ( + tenant_id, + business_id, + vendor_id, + shift_id, + shift_role_id, + workforce_id, + staff_id, + application_id, + status, + assigned_at, + accepted_at, + metadata + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'ACCEPTED', NOW(), NOW(), $9::jsonb) + ON CONFLICT (shift_role_id, workforce_id) DO UPDATE + SET application_id = EXCLUDED.application_id, + status = 'ACCEPTED', + accepted_at = COALESCE(assignments.accepted_at, NOW()), + updated_at = NOW() + RETURNING id, status + `, + [ + context.tenant.tenantId, + shiftRole.business_id, + shiftRole.vendor_id, + shiftRole.shift_id, + shiftRole.shift_role_id, + staff.workforce_id, + staff.id, + applicationResult.rows[0].id, + JSON.stringify({ source: 'staff-apply-instant-book' }), + ] + ); + assignmentId = assignmentResult.rows[0].id; + assignmentStatus = assignmentResult.rows[0].status; + await refreshShiftRoleCounts(client, shiftRole.shift_role_id); + await refreshShiftCounts(client, shiftRole.shift_id); + } + + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'application', + aggregateId: applicationResult.rows[0].id, + eventType: instantBook ? 'SHIFT_INSTANT_BOOKED' : 'SHIFT_APPLIED', + actorUserId: actor.uid, + payload, + }); + + return { + applicationId: applicationResult.rows[0].id, + shiftId: shiftRole.shift_id, + roleId: shiftRole.shift_role_id, + status: instantBook ? 'CONFIRMED' : applicationResult.rows[0].status, + assignmentId, + assignmentStatus, + }; + }); +} + +export async function acceptPendingShift(actor, payload) { + const context = await requireStaffContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const assignment = await requirePendingAssignmentForActor(client, context.tenant.tenantId, payload.shiftId, actor.uid); + await client.query( + ` + UPDATE assignments + SET status = 'ACCEPTED', + accepted_at = COALESCE(accepted_at, NOW()), + metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb, + updated_at = NOW() + WHERE id = $1 + `, + [assignment.id, JSON.stringify({ acceptedBy: actor.uid })] + ); + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'assignment', + aggregateId: assignment.id, + eventType: 'STAFF_PENDING_SHIFT_ACCEPTED', + actorUserId: actor.uid, + payload, + }); + return { + assignmentId: assignment.id, + shiftId: assignment.shift_id, + status: 'ACCEPTED', + }; + }); +} + +export async function declinePendingShift(actor, payload) { + const context = await requireStaffContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const assignment = await requirePendingAssignmentForActor(client, context.tenant.tenantId, payload.shiftId, actor.uid); + await client.query( + ` + UPDATE assignments + SET status = 'CANCELLED', + metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb, + updated_at = NOW() + WHERE id = $1 + `, + [assignment.id, JSON.stringify({ + declinedBy: actor.uid, + declineReason: payload.reason || null, + })] + ); + await client.query( + ` + UPDATE applications + SET status = 'REJECTED', + metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb, + updated_at = NOW() + WHERE shift_role_id = $1 + AND staff_id = $3 + AND status IN ('PENDING', 'CONFIRMED') + `, + [assignment.shift_role_id, JSON.stringify({ rejectedBy: actor.uid, reason: payload.reason || null }), assignment.staff_id] + ); + await refreshShiftRoleCounts(client, assignment.shift_role_id); + await refreshShiftCounts(client, assignment.shift_id); + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'assignment', + aggregateId: assignment.id, + eventType: 'STAFF_PENDING_SHIFT_DECLINED', + actorUserId: actor.uid, + payload, + }); + return { + assignmentId: assignment.id, + shiftId: assignment.shift_id, + status: 'DECLINED', + }; + }); +} + +export async function requestShiftSwap(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 (!['ACCEPTED', 'CHECKED_IN', 'CHECKED_OUT'].includes(assignment.status)) { + throw new AppError('INVALID_SWAP_STATE', 'Only accepted or worked shifts can be marked for swap', 409, { + shiftId: payload.shiftId, + assignmentStatus: assignment.status, + }); + } + await client.query( + ` + UPDATE assignments + SET status = 'SWAP_REQUESTED', + metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb, + updated_at = NOW() + WHERE id = $1 + `, + [assignment.id, JSON.stringify({ + swapRequestedAt: new Date().toISOString(), + swapReason: payload.reason || null, + })] + ); + await refreshShiftRoleCounts(client, assignment.shift_role_id); + await refreshShiftCounts(client, assignment.shift_id); + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'assignment', + aggregateId: assignment.id, + eventType: 'SHIFT_SWAP_REQUESTED', + actorUserId: actor.uid, + payload, + }); + return { + assignmentId: assignment.id, + shiftId: assignment.shift_id, + status: 'SWAP_REQUESTED', + }; + }); +} + +export async function setupStaffProfile(actor, payload) { + return withTransaction(async (client) => { + const scope = await resolveStaffOnboardingScope(client, actor.uid, payload.tenantId, payload.vendorId); + await ensureActorUser(client, actor, { + email: payload.email ?? actor.email ?? null, + displayName: payload.fullName, + phone: payload.phoneNumber, + metadata: { source: 'staff-profile-setup' }, + }); + + await client.query( + ` + INSERT INTO tenant_memberships (tenant_id, user_id, membership_status, base_role, metadata) + VALUES ($1, $2, 'ACTIVE', 'member', '{"source":"staff-profile-setup"}'::jsonb) + ON CONFLICT (tenant_id, user_id) DO UPDATE + SET membership_status = 'ACTIVE', + updated_at = NOW() + `, + [scope.tenantId, actor.uid] + ); + + await client.query( + ` + INSERT INTO vendor_memberships (tenant_id, vendor_id, user_id, membership_status, vendor_role, metadata) + VALUES ($1, $2, $3, 'ACTIVE', 'member', '{"source":"staff-profile-setup"}'::jsonb) + ON CONFLICT (vendor_id, user_id) DO UPDATE + SET membership_status = 'ACTIVE', + updated_at = NOW() + `, + [scope.tenantId, scope.vendorId, actor.uid] + ); + + const fullName = payload.fullName.trim(); + const [firstName, ...lastParts] = fullName.split(/\s+/); + const lastName = lastParts.join(' '); + const metadata = { + bio: payload.bio || null, + firstName, + lastName, + preferredLocations: ensureArray(payload.preferredLocations || []), + maxDistanceMiles: payload.maxDistanceMiles ?? null, + industries: ensureArray(payload.industries || []), + skills: ensureArray(payload.skills || []), + }; + + const staffResult = await client.query( + ` + INSERT INTO staffs ( + tenant_id, + user_id, + full_name, + email, + phone, + status, + primary_role, + onboarding_status, + metadata + ) + VALUES ($1, $2, $3, $4, $5, 'ACTIVE', $6, 'COMPLETED', $7::jsonb) + ON CONFLICT (tenant_id, user_id) DO UPDATE + SET full_name = EXCLUDED.full_name, + email = COALESCE(EXCLUDED.email, staffs.email), + phone = COALESCE(EXCLUDED.phone, staffs.phone), + primary_role = COALESCE(EXCLUDED.primary_role, staffs.primary_role), + onboarding_status = 'COMPLETED', + metadata = COALESCE(staffs.metadata, '{}'::jsonb) || EXCLUDED.metadata, + updated_at = NOW() + RETURNING id + `, + [ + scope.tenantId, + actor.uid, + fullName, + payload.email ?? actor.email ?? null, + normalizePhone(payload.phoneNumber), + payload.primaryRole || ensureArray(payload.skills || [])[0] || 'GENERAL_EVENT_STAFF', + JSON.stringify(metadata), + ] + ); + + const workforceResult = await client.query( + ` + INSERT INTO workforce ( + tenant_id, + vendor_id, + staff_id, + workforce_number, + employment_type, + status, + metadata + ) + VALUES ($1, $2, $3, $4, 'TEMP', 'ACTIVE', '{"source":"staff-profile-setup"}'::jsonb) + ON CONFLICT (vendor_id, staff_id) DO UPDATE + SET status = 'ACTIVE', + updated_at = NOW() + RETURNING id + `, + [ + scope.tenantId, + scope.vendorId, + staffResult.rows[0].id, + `WF-${normalizeSlug(firstName).toUpperCase()}-${crypto.randomUUID().slice(0, 8).toUpperCase()}`, + ] + ); + + await insertDomainEvent(client, { + tenantId: scope.tenantId, + aggregateType: 'staff', + aggregateId: staffResult.rows[0].id, + eventType: 'STAFF_PROFILE_SETUP_COMPLETED', + actorUserId: actor.uid, + payload, + }); + + return { + staffId: staffResult.rows[0].id, + workforceId: workforceResult.rows[0].id, + tenantId: scope.tenantId, + vendorId: scope.vendorId, + completed: true, + }; + }); +} + +export async function updatePersonalInfo(actor, payload) { + const context = await requireStaffContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor, { + email: payload.email ?? actor.email ?? null, + displayName: payload.displayName || null, + phone: payload.phone || null, + }); + const staff = await requireStaffByActor(client, context.tenant.tenantId, actor.uid); + const existingMetadata = staff.metadata || {}; + const nextMetadata = buildStaffMetadataPatch(existingMetadata, payload); + const fullName = [ + payload.firstName || existingMetadata.firstName || staff.full_name.split(' ')[0] || '', + payload.lastName || existingMetadata.lastName || staff.full_name.split(' ').slice(1).join(' ') || '', + ].filter(Boolean).join(' ').trim() || staff.full_name; + + await client.query( + ` + UPDATE staffs + SET full_name = $2, + email = COALESCE($3, email), + phone = COALESCE($4, phone), + metadata = $5::jsonb, + updated_at = NOW() + WHERE id = $1 + `, + [staff.id, fullName, payload.email ?? null, normalizePhone(payload.phone) ?? null, JSON.stringify(nextMetadata)] + ); + + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'staff', + aggregateId: staff.id, + eventType: 'STAFF_PERSONAL_INFO_UPDATED', + actorUserId: actor.uid, + payload, + }); + + return { + staffId: staff.id, + fullName, + email: payload.email ?? staff.email ?? null, + phone: normalizePhone(payload.phone) ?? staff.phone ?? null, + metadata: nextMetadata, + }; + }); +} + +export async function updateProfileExperience(actor, payload) { + const context = await requireStaffContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const staff = await requireStaffByActor(client, context.tenant.tenantId, actor.uid); + const nextMetadata = buildStaffMetadataPatch(staff.metadata || {}, payload); + await client.query( + ` + UPDATE staffs + SET metadata = $2::jsonb, + primary_role = COALESCE($3, primary_role), + updated_at = NOW() + WHERE id = $1 + `, + [ + staff.id, + JSON.stringify(nextMetadata), + payload.primaryRole || null, + ] + ); + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'staff', + aggregateId: staff.id, + eventType: 'STAFF_EXPERIENCE_UPDATED', + actorUserId: actor.uid, + payload, + }); + return { + staffId: staff.id, + industries: ensureArray(nextMetadata.industries || []), + skills: ensureArray(nextMetadata.skills || []), + }; + }); +} + +export async function updatePreferredLocations(actor, payload) { + return updatePersonalInfo(actor, { + preferredLocations: payload.preferredLocations, + maxDistanceMiles: payload.maxDistanceMiles, + }); +} + +export async function createEmergencyContact(actor, payload) { + const context = await requireStaffContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const staff = await requireStaffByActor(client, context.tenant.tenantId, actor.uid); + const result = await client.query( + ` + INSERT INTO emergency_contacts ( + tenant_id, + staff_id, + full_name, + phone, + relationship_type, + is_primary, + metadata + ) + VALUES ($1, $2, $3, $4, $5, COALESCE($6, FALSE), $7::jsonb) + RETURNING id + `, + [ + context.tenant.tenantId, + staff.id, + payload.fullName, + normalizePhone(payload.phone), + payload.relationshipType, + payload.isPrimary ?? false, + JSON.stringify(payload.metadata || {}), + ] + ); + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'emergency_contact', + aggregateId: result.rows[0].id, + eventType: 'EMERGENCY_CONTACT_CREATED', + actorUserId: actor.uid, + payload, + }); + return { contactId: result.rows[0].id, created: true }; + }); +} + +export async function updateEmergencyContact(actor, payload) { + const context = await requireStaffContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const staff = await requireStaffByActor(client, context.tenant.tenantId, actor.uid); + const result = await client.query( + ` + UPDATE emergency_contacts + SET full_name = COALESCE($2, full_name), + phone = COALESCE($3, phone), + relationship_type = COALESCE($4, relationship_type), + is_primary = COALESCE($5, is_primary), + metadata = COALESCE(metadata, '{}'::jsonb) || $6::jsonb, + updated_at = NOW() + WHERE id = $1 + AND tenant_id = $7 + AND staff_id = $8 + RETURNING id + `, + [ + payload.contactId, + payload.fullName || null, + normalizePhone(payload.phone) || null, + payload.relationshipType || null, + payload.isPrimary ?? null, + JSON.stringify(payload.metadata || {}), + context.tenant.tenantId, + staff.id, + ] + ); + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Emergency contact not found for current staff user', 404, { + contactId: payload.contactId, + }); + } + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'emergency_contact', + aggregateId: result.rows[0].id, + eventType: 'EMERGENCY_CONTACT_UPDATED', + actorUserId: actor.uid, + payload, + }); + return { contactId: result.rows[0].id, updated: true }; + }); +} + +async function ensureTaxFormDocument(client, tenantId, formType) { + const normalizedName = formType.toUpperCase() === 'I9' ? 'I-9' : 'W-4'; + const result = await client.query( + ` + INSERT INTO documents (tenant_id, document_type, name, metadata) + VALUES ($1, 'TAX_FORM', $2, '{"required":true}'::jsonb) + ON CONFLICT (tenant_id, document_type, name) DO UPDATE + SET updated_at = NOW() + RETURNING id, name + `, + [tenantId, normalizedName] + ); + return result.rows[0]; +} + +export async function saveTaxFormDraft(actor, payload) { + const context = await requireStaffContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const staff = await requireStaffByActor(client, context.tenant.tenantId, actor.uid); + const document = await ensureTaxFormDocument(client, context.tenant.tenantId, payload.formType); + const result = await client.query( + ` + INSERT INTO staff_documents ( + tenant_id, + staff_id, + document_id, + file_uri, + status, + metadata + ) + VALUES ($1, $2, $3, NULL, 'PENDING', $4::jsonb) + ON CONFLICT (staff_id, document_id) DO UPDATE + SET metadata = COALESCE(staff_documents.metadata, '{}'::jsonb) || EXCLUDED.metadata, + updated_at = NOW() + RETURNING id + `, + [ + context.tenant.tenantId, + staff.id, + document.id, + JSON.stringify({ + formType: document.name, + formStatus: 'IN_PROGRESS', + fields: payload.fields, + lastSavedAt: new Date().toISOString(), + }), + ] + ); + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'staff_document', + aggregateId: result.rows[0].id, + eventType: 'TAX_FORM_DRAFT_SAVED', + actorUserId: actor.uid, + payload, + }); + return { staffDocumentId: result.rows[0].id, formType: document.name, status: 'IN_PROGRESS' }; + }); +} + +export async function submitTaxForm(actor, payload) { + const context = await requireStaffContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const staff = await requireStaffByActor(client, context.tenant.tenantId, actor.uid); + const document = await ensureTaxFormDocument(client, context.tenant.tenantId, payload.formType); + const result = await client.query( + ` + INSERT INTO staff_documents ( + tenant_id, + staff_id, + document_id, + file_uri, + status, + metadata + ) + VALUES ($1, $2, $3, NULL, 'PENDING', $4::jsonb) + ON CONFLICT (staff_id, document_id) DO UPDATE + SET metadata = COALESCE(staff_documents.metadata, '{}'::jsonb) || EXCLUDED.metadata, + updated_at = NOW() + RETURNING id + `, + [ + context.tenant.tenantId, + staff.id, + document.id, + JSON.stringify({ + formType: document.name, + formStatus: 'SUBMITTED', + submittedAt: new Date().toISOString(), + fields: payload.fields, + }), + ] + ); + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'staff_document', + aggregateId: result.rows[0].id, + eventType: 'TAX_FORM_SUBMITTED', + actorUserId: actor.uid, + payload, + }); + return { staffDocumentId: result.rows[0].id, formType: document.name, status: 'SUBMITTED' }; + }); +} + +export async function addStaffBankAccount(actor, payload) { + const context = await requireStaffContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const staff = await requireStaffByActor(client, context.tenant.tenantId, actor.uid); + const existingPrimary = await client.query( + ` + SELECT id + FROM accounts + WHERE tenant_id = $1 + AND owner_staff_id = $2 + AND is_primary = TRUE + LIMIT 1 + `, + [context.tenant.tenantId, staff.id] + ); + const accountResult = await client.query( + ` + INSERT INTO accounts ( + tenant_id, + owner_type, + owner_staff_id, + provider_name, + provider_reference, + last4, + is_primary, + metadata + ) + VALUES ($1, 'STAFF', $2, $3, $4, $5, $6, $7::jsonb) + RETURNING id, last4, is_primary + `, + [ + context.tenant.tenantId, + staff.id, + payload.bankName, + `manual:${payload.routingNumber.slice(-4)}:${payload.accountNumber.slice(-4)}`, + payload.accountNumber.slice(-4), + existingPrimary.rowCount === 0, + JSON.stringify({ + accountType: payload.accountType, + routingNumberMasked: `***${payload.routingNumber.slice(-4)}`, + }), + ] + ); + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'account', + aggregateId: accountResult.rows[0].id, + eventType: 'STAFF_BANK_ACCOUNT_ADDED', + actorUserId: actor.uid, + payload: { + accountType: payload.accountType, + bankName: payload.bankName, + }, + }); + return { + accountId: accountResult.rows[0].id, + last4: accountResult.rows[0].last4, + isPrimary: accountResult.rows[0].is_primary, + }; + }); +} + +export async function updatePrivacyVisibility(actor, payload) { + const context = await requireStaffContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const staff = await requireStaffByActor(client, context.tenant.tenantId, actor.uid); + const nextMetadata = buildStaffMetadataPatch(staff.metadata || {}, { + profileVisible: payload.profileVisible, + }); + await client.query( + ` + UPDATE staffs + SET metadata = $2::jsonb, + updated_at = NOW() + WHERE id = $1 + `, + [staff.id, JSON.stringify(nextMetadata)] + ); + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'staff', + aggregateId: staff.id, + eventType: 'STAFF_PRIVACY_UPDATED', + actorUserId: actor.uid, + payload, + }); + return { + staffId: staff.id, + profileVisible: Boolean(payload.profileVisible), + }; + }); +} diff --git a/backend/command-api/test/mobile-routes.test.js b/backend/command-api/test/mobile-routes.test.js new file mode 100644 index 00000000..3870066a --- /dev/null +++ b/backend/command-api/test/mobile-routes.test.js @@ -0,0 +1,233 @@ +import test, { beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import request from 'supertest'; +import { createApp } from '../src/app.js'; +import { __resetIdempotencyStoreForTests } from '../src/services/idempotency-store.js'; + +process.env.AUTH_BYPASS = 'true'; + +beforeEach(() => { + process.env.IDEMPOTENCY_STORE = 'memory'; + delete process.env.IDEMPOTENCY_DATABASE_URL; + delete process.env.DATABASE_URL; + __resetIdempotencyStoreForTests(); +}); + +function createMobileHandlers() { + return { + createClientOneTimeOrder: async (_actor, payload) => ({ + orderId: 'order-1', + orderType: 'ONE_TIME', + eventName: payload.eventName, + }), + createClientRecurringOrder: async (_actor, payload) => ({ + orderId: 'order-2', + orderType: 'RECURRING', + recurrenceDays: payload.recurrenceDays, + }), + createClientPermanentOrder: async (_actor, payload) => ({ + orderId: 'order-3', + orderType: 'PERMANENT', + horizonDays: payload.horizonDays || 28, + }), + createEditedOrderCopy: async (_actor, payload) => ({ + sourceOrderId: payload.orderId, + orderId: 'order-4', + cloned: true, + }), + cancelClientOrder: async (_actor, payload) => ({ + orderId: payload.orderId, + status: 'CANCELLED', + }), + createHub: async (_actor, payload) => ({ + hubId: 'hub-1', + name: payload.name, + costCenterId: payload.costCenterId, + }), + approveInvoice: async (_actor, payload) => ({ + invoiceId: payload.invoiceId, + status: 'APPROVED', + }), + applyForShift: async (_actor, payload) => ({ + shiftId: payload.shiftId, + status: 'APPLIED', + }), + staffClockIn: async (_actor, payload) => ({ + assignmentId: payload.assignmentId || 'assignment-1', + status: 'CLOCK_IN', + }), + staffClockOut: async (_actor, payload) => ({ + assignmentId: payload.assignmentId || 'assignment-1', + status: 'CLOCK_OUT', + }), + saveTaxFormDraft: async (_actor, payload) => ({ + formType: payload.formType, + status: 'DRAFT', + }), + addStaffBankAccount: async (_actor, payload) => ({ + accountType: payload.accountType, + last4: payload.accountNumber.slice(-4), + }), + }; +} + +test('POST /commands/client/orders/one-time forwards one-time order payload', async () => { + const app = createApp({ mobileCommandHandlers: createMobileHandlers() }); + const res = await request(app) + .post('/commands/client/orders/one-time') + .set('Authorization', 'Bearer test-token') + .set('Idempotency-Key', 'client-order-1') + .send({ + hubId: '11111111-1111-4111-8111-111111111111', + vendorId: '22222222-2222-4222-8222-222222222222', + eventName: 'Google Cafe Coverage', + orderDate: '2026-03-20', + positions: [ + { + roleId: '33333333-3333-4333-8333-333333333333', + startTime: '09:00', + endTime: '17:00', + workerCount: 2, + hourlyRateCents: 2800, + }, + ], + }); + + assert.equal(res.status, 200); + assert.equal(res.body.orderId, 'order-1'); + assert.equal(res.body.orderType, 'ONE_TIME'); + assert.equal(res.body.eventName, 'Google Cafe Coverage'); +}); + +test('POST /commands/client/orders/:orderId/edit injects order id from params', async () => { + const app = createApp({ mobileCommandHandlers: createMobileHandlers() }); + const res = await request(app) + .post('/commands/client/orders/44444444-4444-4444-8444-444444444444/edit') + .set('Authorization', 'Bearer test-token') + .set('Idempotency-Key', 'client-order-edit-1') + .send({ + eventName: 'Edited Order Copy', + }); + + assert.equal(res.status, 200); + assert.equal(res.body.sourceOrderId, '44444444-4444-4444-8444-444444444444'); + assert.equal(res.body.cloned, true); +}); + +test('POST /commands/client/hubs returns injected hub response', async () => { + const app = createApp({ mobileCommandHandlers: createMobileHandlers() }); + const res = await request(app) + .post('/commands/client/hubs') + .set('Authorization', 'Bearer test-token') + .set('Idempotency-Key', 'hub-create-1') + .send({ + tenantId: '11111111-1111-4111-8111-111111111111', + businessId: '22222222-2222-4222-8222-222222222222', + name: 'Google North Hub', + locationName: 'North Campus', + timezone: 'America/Los_Angeles', + latitude: 37.422, + longitude: -122.084, + geofenceRadiusMeters: 100, + costCenterId: '44444444-4444-4444-8444-444444444444', + }); + + assert.equal(res.status, 200); + assert.equal(res.body.hubId, 'hub-1'); + assert.equal(res.body.name, 'Google North Hub'); +}); + +test('POST /commands/client/billing/invoices/:invoiceId/approve injects invoice id from params', async () => { + const app = createApp({ mobileCommandHandlers: createMobileHandlers() }); + const res = await request(app) + .post('/commands/client/billing/invoices/55555555-5555-4555-8555-555555555555/approve') + .set('Authorization', 'Bearer test-token') + .set('Idempotency-Key', 'invoice-approve-1') + .send({}); + + assert.equal(res.status, 200); + assert.equal(res.body.invoiceId, '55555555-5555-4555-8555-555555555555'); + assert.equal(res.body.status, 'APPROVED'); +}); + +test('POST /commands/staff/shifts/:shiftId/apply injects shift id from params', async () => { + const app = createApp({ mobileCommandHandlers: createMobileHandlers() }); + const res = await request(app) + .post('/commands/staff/shifts/66666666-6666-4666-8666-666666666666/apply') + .set('Authorization', 'Bearer test-token') + .set('Idempotency-Key', 'shift-apply-1') + .send({ + note: 'Available tonight', + }); + + assert.equal(res.status, 200); + assert.equal(res.body.shiftId, '66666666-6666-4666-8666-666666666666'); + assert.equal(res.body.status, 'APPLIED'); +}); + +test('POST /commands/staff/clock-in accepts shift-based payload', async () => { + const app = createApp({ mobileCommandHandlers: createMobileHandlers() }); + const res = await request(app) + .post('/commands/staff/clock-in') + .set('Authorization', 'Bearer test-token') + .set('Idempotency-Key', 'clock-in-1') + .send({ + shiftId: '77777777-7777-4777-8777-777777777777', + sourceType: 'GEO', + latitude: 37.422, + longitude: -122.084, + }); + + assert.equal(res.status, 200); + assert.equal(res.body.status, 'CLOCK_IN'); +}); + +test('POST /commands/staff/clock-out accepts assignment-based payload', async () => { + const app = createApp({ mobileCommandHandlers: createMobileHandlers() }); + const res = await request(app) + .post('/commands/staff/clock-out') + .set('Authorization', 'Bearer test-token') + .set('Idempotency-Key', 'clock-out-1') + .send({ + assignmentId: '88888888-8888-4888-8888-888888888888', + breakMinutes: 30, + }); + + assert.equal(res.status, 200); + assert.equal(res.body.status, 'CLOCK_OUT'); +}); + +test('PUT /commands/staff/profile/tax-forms/:formType uppercases form type', async () => { + const app = createApp({ mobileCommandHandlers: createMobileHandlers() }); + const res = await request(app) + .put('/commands/staff/profile/tax-forms/w4') + .set('Authorization', 'Bearer test-token') + .set('Idempotency-Key', 'tax-form-1') + .send({ + fields: { + filingStatus: 'single', + }, + }); + + assert.equal(res.status, 200); + assert.equal(res.body.formType, 'W4'); + assert.equal(res.body.status, 'DRAFT'); +}); + +test('POST /commands/staff/profile/bank-accounts uppercases account type', async () => { + const app = createApp({ mobileCommandHandlers: createMobileHandlers() }); + const res = await request(app) + .post('/commands/staff/profile/bank-accounts') + .set('Authorization', 'Bearer test-token') + .set('Idempotency-Key', 'bank-account-1') + .send({ + bankName: 'Demo Credit Union', + accountNumber: '1234567890', + routingNumber: '021000021', + accountType: 'checking', + }); + + assert.equal(res.status, 200); + assert.equal(res.body.accountType, 'CHECKING'); + assert.equal(res.body.last4, '7890'); +}); diff --git a/backend/core-api/package-lock.json b/backend/core-api/package-lock.json index 87370c92..3155f437 100644 --- a/backend/core-api/package-lock.json +++ b/backend/core-api/package-lock.json @@ -13,6 +13,7 @@ "firebase-admin": "^13.0.2", "google-auth-library": "^9.15.1", "multer": "^2.0.2", + "pg": "^8.20.0", "pino": "^9.6.0", "pino-http": "^10.3.0", "zod": "^3.24.2" @@ -2037,6 +2038,95 @@ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "license": "MIT" }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/pino": { "version": "9.14.0", "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", @@ -2086,6 +2176,45 @@ "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", "license": "MIT" }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/process-warning": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", diff --git a/backend/core-api/package.json b/backend/core-api/package.json index 0e9b2f6d..cf7bccca 100644 --- a/backend/core-api/package.json +++ b/backend/core-api/package.json @@ -16,6 +16,7 @@ "firebase-admin": "^13.0.2", "google-auth-library": "^9.15.1", "multer": "^2.0.2", + "pg": "^8.20.0", "pino": "^9.6.0", "pino-http": "^10.3.0", "zod": "^3.24.2" diff --git a/backend/core-api/src/routes/core.js b/backend/core-api/src/routes/core.js index 6c905278..40a0ebe8 100644 --- a/backend/core-api/src/routes/core.js +++ b/backend/core-api/src/routes/core.js @@ -24,6 +24,12 @@ import { retryVerificationJob, reviewVerificationJob, } from '../services/verification-jobs.js'; +import { + deleteCertificate, + uploadCertificate, + uploadProfilePhoto, + uploadStaffDocument, +} from '../services/mobile-upload.js'; const DEFAULT_MAX_FILE_BYTES = 10 * 1024 * 1024; const DEFAULT_MAX_SIGNED_URL_SECONDS = 900; @@ -56,6 +62,14 @@ const uploadMetaSchema = z.object({ visibility: z.enum(['public', 'private']).optional(), }); +const certificateUploadMetaSchema = z.object({ + certificateType: z.string().min(1).max(120), + name: z.string().min(1).max(160), + issuer: z.string().max(160).optional(), + certificateNumber: z.string().max(160).optional(), + expiresAt: z.string().datetime().optional(), +}); + function mockSignedUrl(fileUri, expiresInSeconds) { const encoded = encodeURIComponent(fileUri); const expiresAt = new Date(Date.now() + expiresInSeconds * 1000).toISOString(); @@ -292,7 +306,7 @@ async function handleCreateVerification(req, res, next) { }); } - const created = createVerificationJob({ + const created = await createVerificationJob({ actorUid: req.actor.uid, payload, }); @@ -305,10 +319,107 @@ async function handleCreateVerification(req, res, next) { } } +async function handleProfilePhotoUpload(req, res, next) { + try { + const file = req.file; + if (!file) { + throw new AppError('INVALID_FILE', 'Missing file in multipart form data', 400); + } + const result = await uploadProfilePhoto({ + actorUid: req.actor.uid, + file, + }); + return res.status(200).json({ + ...result, + requestId: req.requestId, + }); + } catch (error) { + return next(error); + } +} + +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); + } + const result = await uploadStaffDocument({ + actorUid: req.actor.uid, + documentId: req.params.documentId, + file, + routeType: 'document', + }); + return res.status(200).json({ + ...result, + requestId: req.requestId, + }); + } catch (error) { + return next(error); + } +} + +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); + } + const result = await uploadStaffDocument({ + actorUid: req.actor.uid, + documentId: req.params.documentId, + file, + routeType: 'attire', + }); + return res.status(200).json({ + ...result, + requestId: req.requestId, + }); + } catch (error) { + return next(error); + } +} + +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); + } + 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, + }); + } catch (error) { + return next(error); + } +} + +async function handleCertificateDelete(req, res, next) { + try { + const result = await deleteCertificate({ + actorUid: req.actor.uid, + certificateType: req.params.certificateType, + }); + return res.status(200).json({ + ...result, + requestId: req.requestId, + }); + } catch (error) { + return next(error); + } +} + async function handleGetVerification(req, res, next) { try { const verificationId = req.params.verificationId; - const job = getVerificationJob(verificationId, req.actor.uid); + const job = await getVerificationJob(verificationId, req.actor.uid); return res.status(200).json({ ...job, requestId: req.requestId, @@ -322,7 +433,7 @@ async function handleReviewVerification(req, res, next) { try { const verificationId = req.params.verificationId; const payload = parseBody(reviewVerificationSchema, req.body || {}); - const updated = reviewVerificationJob(verificationId, req.actor.uid, payload); + const updated = await reviewVerificationJob(verificationId, req.actor.uid, payload); return res.status(200).json({ ...updated, requestId: req.requestId, @@ -335,7 +446,7 @@ async function handleReviewVerification(req, res, next) { async function handleRetryVerification(req, res, next) { try { const verificationId = req.params.verificationId; - const updated = retryVerificationJob(verificationId, req.actor.uid); + const updated = await retryVerificationJob(verificationId, req.actor.uid); return res.status(202).json({ ...updated, requestId: req.requestId, @@ -353,6 +464,11 @@ 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('/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/attire/:documentId/upload', requireAuth, requirePolicy('core.upload', 'file'), upload.single('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); router.get('/verifications/:verificationId', requireAuth, requirePolicy('core.verification.read', 'verification'), handleGetVerification); router.post('/verifications/:verificationId/review', requireAuth, requirePolicy('core.verification.review', 'verification'), handleReviewVerification); diff --git a/backend/core-api/src/routes/health.js b/backend/core-api/src/routes/health.js index 9196cc83..9ccd539c 100644 --- a/backend/core-api/src/routes/health.js +++ b/backend/core-api/src/routes/health.js @@ -1,4 +1,5 @@ import { Router } from 'express'; +import { checkDatabaseHealth, isDatabaseConfigured } from '../services/db.js'; export const healthRouter = Router(); @@ -13,3 +14,31 @@ function healthHandler(req, res) { healthRouter.get('/health', healthHandler); healthRouter.get('/healthz', healthHandler); + +healthRouter.get('/readyz', async (req, res) => { + if (!isDatabaseConfigured()) { + return res.status(503).json({ + ok: false, + service: 'krow-core-api', + status: 'DATABASE_NOT_CONFIGURED', + requestId: req.requestId, + }); + } + + const healthy = await checkDatabaseHealth().catch(() => false); + if (!healthy) { + return res.status(503).json({ + ok: false, + service: 'krow-core-api', + status: 'DATABASE_UNAVAILABLE', + requestId: req.requestId, + }); + } + + return res.status(200).json({ + ok: true, + service: 'krow-core-api', + status: 'READY', + requestId: req.requestId, + }); +}); diff --git a/backend/core-api/src/services/actor-context.js b/backend/core-api/src/services/actor-context.js new file mode 100644 index 00000000..ae32d932 --- /dev/null +++ b/backend/core-api/src/services/actor-context.js @@ -0,0 +1,67 @@ +import { AppError } from '../lib/errors.js'; +import { query } from './db.js'; + +export async function loadActorContext(uid) { + const [userResult, tenantResult, 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 s.id AS "staffId", + s.tenant_id AS "tenantId", + s.full_name AS "fullName", + s.status, + s.metadata + FROM staffs s + 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, + staff: staffResult.rows[0] || null, + }; +} + +export async function requireTenantContext(uid) { + const context = await loadActorContext(uid); + if (!context.user || !context.tenant) { + throw new AppError('FORBIDDEN', 'Tenant 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; +} diff --git a/backend/core-api/src/services/db.js b/backend/core-api/src/services/db.js new file mode 100644 index 00000000..2755155c --- /dev/null +++ b/backend/core-api/src/services/db.js @@ -0,0 +1,98 @@ +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; + +function parseIntOrDefault(value, fallback) { + const parsed = Number.parseInt(`${value || fallback}`, 10); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function resolveDatabasePoolConfig() { + if (process.env.DATABASE_URL) { + return { + connectionString: process.env.DATABASE_URL, + max: parseIntOrDefault(process.env.DB_POOL_MAX, 10), + idleTimeoutMillis: parseIntOrDefault(process.env.DB_IDLE_TIMEOUT_MS, 30000), + }; + } + + const user = process.env.DB_USER; + const password = process.env.DB_PASSWORD; + const database = process.env.DB_NAME; + const host = process.env.DB_HOST || ( + process.env.INSTANCE_CONNECTION_NAME + ? `/cloudsql/${process.env.INSTANCE_CONNECTION_NAME}` + : '' + ); + + if (!user || password == null || !database || !host) { + return null; + } + + return { + host, + port: parseIntOrDefault(process.env.DB_PORT, 5432), + user, + password, + database, + max: parseIntOrDefault(process.env.DB_POOL_MAX, 10), + idleTimeoutMillis: parseIntOrDefault(process.env.DB_IDLE_TIMEOUT_MS, 30000), + }; +} + +export function isDatabaseConfigured() { + return Boolean(resolveDatabasePoolConfig()); +} + +function getPool() { + if (!pool) { + const resolved = resolveDatabasePoolConfig(); + if (!resolved) { + throw new Error('Database connection settings are required'); + } + pool = new Pool(resolved); + } + return pool; +} + +export async function query(text, params = []) { + return getPool().query(text, params); +} + +export async function withTransaction(work) { + const client = await getPool().connect(); + try { + await client.query('BEGIN'); + const result = await work(client); + await client.query('COMMIT'); + return result; + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } +} + +export async function checkDatabaseHealth() { + const result = await query('SELECT 1 AS ok'); + return result.rows[0]?.ok === 1; +} + +export async function closePool() { + if (pool) { + await pool.end(); + pool = null; + } +} diff --git a/backend/core-api/src/services/mobile-upload.js b/backend/core-api/src/services/mobile-upload.js new file mode 100644 index 00000000..392c9076 --- /dev/null +++ b/backend/core-api/src/services/mobile-upload.js @@ -0,0 +1,260 @@ +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'; + +function safeName(value) { + return `${value}`.replace(/[^a-zA-Z0-9._-]/g, '_'); +} + +function uploadBucket() { + return process.env.PRIVATE_BUCKET || 'krow-workforce-dev-private'; +} + +async function uploadActorFile({ actorUid, file, category }) { + const bucket = uploadBucket(); + const objectPath = `uploads/${actorUid}/${category}/${Date.now()}_${safeName(file.originalname)}`; + const fileUri = `gs://${bucket}/${objectPath}`; + await uploadToGcs({ + bucket, + objectPath, + contentType: file.mimetype, + buffer: file.buffer, + }); + return { bucket, objectPath, fileUri }; +} + +async function createPreviewUrl(actorUid, fileUri) { + try { + return await generateReadSignedUrl({ + fileUri, + actorUid, + expiresInSeconds: 900, + }); + } catch { + return { + signedUrl: null, + expiresAt: null, + }; + } +} + +export async function uploadProfilePhoto({ actorUid, file }) { + const context = await requireStaffContext(actorUid); + const uploaded = await uploadActorFile({ + actorUid, + file, + category: 'profile-photo', + }); + + await withTransaction(async (client) => { + await client.query( + ` + UPDATE staffs + SET metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb, + updated_at = NOW() + WHERE id = $1 + `, + [context.staff.staffId, JSON.stringify({ profilePhotoUri: uploaded.fileUri })] + ); + }); + + const preview = await createPreviewUrl(actorUid, uploaded.fileUri); + return { + staffId: context.staff.staffId, + fileUri: uploaded.fileUri, + signedUrl: preview.signedUrl, + expiresAt: preview.expiresAt, + }; +} + +async function requireDocument(tenantId, documentId, allowedTypes) { + const result = await query( + ` + SELECT id, document_type, name + FROM documents + WHERE tenant_id = $1 + AND id = $2 + AND document_type = ANY($3::text[]) + `, + [tenantId, documentId, allowedTypes] + ); + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Document not found for requested upload type', 404, { + documentId, + allowedTypes, + }); + } + return result.rows[0]; +} + +export async function uploadStaffDocument({ actorUid, documentId, file, routeType }) { + const context = await requireStaffContext(actorUid); + const document = await requireDocument( + context.tenant.tenantId, + documentId, + routeType === 'attire' ? ['ATTIRE'] : ['DOCUMENT', 'GOVERNMENT_ID', 'TAX_FORM'] + ); + const uploaded = await uploadActorFile({ + actorUid, + file, + category: routeType, + }); + const verification = await createVerificationJob({ + actorUid, + payload: { + type: routeType === 'attire' ? 'attire' : 'government_id', + subjectType: routeType === 'attire' ? 'attire_item' : 'staff_document', + subjectId: documentId, + fileUri: uploaded.fileUri, + metadata: { + routeType, + documentType: document.document_type, + }, + rules: { + expectedDocumentName: document.name, + }, + }, + }); + + 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, 'PENDING', $5, $6::jsonb) + ON CONFLICT (staff_id, document_id) DO UPDATE + SET file_uri = EXCLUDED.file_uri, + status = 'PENDING', + 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, + uploaded.fileUri, + verification.verificationId, + JSON.stringify({ + verificationStatus: verification.status, + routeType, + }), + ] + ); + }); + + const preview = await createPreviewUrl(actorUid, uploaded.fileUri); + return { + documentId: document.id, + documentType: document.document_type, + fileUri: uploaded.fileUri, + signedUrl: preview.signedUrl, + expiresAt: preview.expiresAt, + verification, + }; +} + +export async function uploadCertificate({ actorUid, file, payload }) { + const context = await requireStaffContext(actorUid); + const uploaded = await uploadActorFile({ + actorUid, + file, + category: 'certificate', + }); + const verification = await createVerificationJob({ + actorUid, + payload: { + type: 'certification', + subjectType: 'certificate', + subjectId: payload.certificateType, + fileUri: uploaded.fileUri, + metadata: { + certificateType: payload.certificateType, + name: payload.name, + issuer: payload.issuer || null, + certificateNumber: payload.certificateNumber || null, + }, + rules: { + certificateType: payload.certificateType, + name: payload.name, + }, + }, + }); + + const certificateResult = await withTransaction(async (client) => { + 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, 'PENDING', $6, $7, $8::jsonb) + RETURNING id + `, + [ + context.tenant.tenantId, + context.staff.staffId, + payload.certificateType, + payload.certificateNumber || null, + payload.expiresAt || null, + uploaded.fileUri, + verification.verificationId, + JSON.stringify({ + name: payload.name, + issuer: payload.issuer || null, + verificationStatus: verification.status, + }), + ] + ); + }); + + const preview = await createPreviewUrl(actorUid, uploaded.fileUri); + return { + certificateId: certificateResult.rows[0].id, + certificateType: payload.certificateType, + fileUri: uploaded.fileUri, + signedUrl: preview.signedUrl, + expiresAt: preview.expiresAt, + verification, + }; +} + +export async function deleteCertificate({ actorUid, certificateType }) { + const context = await requireStaffContext(actorUid); + const result = await query( + ` + DELETE FROM certificates + WHERE tenant_id = $1 + AND staff_id = $2 + AND certificate_type = $3 + RETURNING id + `, + [context.tenant.tenantId, context.staff.staffId, certificateType] + ); + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Certificate not found for current staff user', 404, { + certificateType, + }); + } + return { + certificateId: result.rows[0].id, + deleted: true, + }; +} diff --git a/backend/core-api/src/services/verification-jobs.js b/backend/core-api/src/services/verification-jobs.js index 5ffe44bd..ce46679b 100644 --- a/backend/core-api/src/services/verification-jobs.js +++ b/backend/core-api/src/services/verification-jobs.js @@ -1,9 +1,8 @@ -import crypto from 'node:crypto'; import { AppError } from '../lib/errors.js'; +import { isDatabaseConfigured, query, withTransaction } from './db.js'; +import { requireTenantContext } from './actor-context.js'; import { invokeVertexMultimodalModel } from './llm.js'; -const jobs = new Map(); - export const VerificationStatus = Object.freeze({ PENDING: 'PENDING', PROCESSING: 'PROCESSING', @@ -15,82 +14,96 @@ export const VerificationStatus = Object.freeze({ ERROR: 'ERROR', }); -const MACHINE_TERMINAL_STATUSES = new Set([ - VerificationStatus.AUTO_PASS, - VerificationStatus.AUTO_FAIL, - VerificationStatus.NEEDS_REVIEW, - VerificationStatus.ERROR, -]); - const HUMAN_TERMINAL_STATUSES = new Set([ VerificationStatus.APPROVED, VerificationStatus.REJECTED, ]); -function nowIso() { - return new Date().toISOString(); +const memoryVerificationJobs = new Map(); + +function useMemoryStore() { + if (process.env.VERIFICATION_STORE === 'memory') { + return true; + } + return !isDatabaseConfigured() && (process.env.NODE_ENV === 'test' || process.env.AUTH_BYPASS === 'true'); +} + +function nextVerificationId() { + if (typeof crypto?.randomUUID === 'function') { + return crypto.randomUUID(); + } + return `verification_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; +} + +function loadMemoryJob(verificationId) { + const job = memoryVerificationJobs.get(verificationId); + if (!job) { + throw new AppError('NOT_FOUND', 'Verification not found', 404, { + verificationId, + }); + } + return job; +} + +async function processVerificationJobInMemory(verificationId) { + const job = memoryVerificationJobs.get(verificationId); + if (!job || job.status !== VerificationStatus.PENDING) { + return; + } + + job.status = VerificationStatus.PROCESSING; + job.updated_at = new Date().toISOString(); + memoryVerificationJobs.set(verificationId, job); + + const workItem = { + id: job.id, + type: job.type, + fileUri: job.file_uri, + subjectType: job.subject_type, + subjectId: job.subject_id, + rules: job.metadata?.rules || {}, + metadata: job.metadata || {}, + }; + + try { + const result = workItem.type === 'attire' + ? await runAttireChecks(workItem) + : await runThirdPartyChecks(workItem, workItem.type); + + const updated = { + ...job, + status: result.status, + confidence: result.confidence, + reasons: result.reasons || [], + extracted: result.extracted || {}, + provider_name: result.provider?.name || null, + provider_reference: result.provider?.reference || null, + updated_at: new Date().toISOString(), + }; + memoryVerificationJobs.set(verificationId, updated); + } catch (error) { + const updated = { + ...job, + status: VerificationStatus.ERROR, + reasons: [error?.message || 'Verification processing failed'], + provider_name: 'verification-worker', + provider_reference: `error:${error?.code || 'unknown'}`, + updated_at: new Date().toISOString(), + }; + memoryVerificationJobs.set(verificationId, updated); + } } function accessMode() { return process.env.VERIFICATION_ACCESS_MODE || 'authenticated'; } -function eventRecord({ fromStatus, toStatus, actorType, actorId, details = {} }) { - return { - id: crypto.randomUUID(), - fromStatus, - toStatus, - actorType, - actorId, - details, - createdAt: nowIso(), - }; +function providerTimeoutMs() { + return Number.parseInt(process.env.VERIFICATION_PROVIDER_TIMEOUT_MS || '8000', 10); } -function toPublicJob(job) { - return { - verificationId: job.id, - type: job.type, - subjectType: job.subjectType, - subjectId: job.subjectId, - fileUri: job.fileUri, - status: job.status, - confidence: job.confidence, - reasons: job.reasons, - extracted: job.extracted, - provider: job.provider, - review: job.review, - createdAt: job.createdAt, - updatedAt: job.updatedAt, - }; -} - -function assertAccess(job, actorUid) { - if (accessMode() === 'authenticated') { - return; - } - if (job.ownerUid !== actorUid) { - throw new AppError('FORBIDDEN', 'Not allowed to access this verification', 403); - } -} - -function requireJob(id) { - const job = jobs.get(id); - if (!job) { - throw new AppError('NOT_FOUND', 'Verification not found', 404, { verificationId: id }); - } - return job; -} - -function normalizeMachineStatus(status) { - if ( - status === VerificationStatus.AUTO_PASS - || status === VerificationStatus.AUTO_FAIL - || status === VerificationStatus.NEEDS_REVIEW - ) { - return status; - } - return VerificationStatus.NEEDS_REVIEW; +function attireModel() { + return process.env.VERIFICATION_ATTIRE_MODEL || 'gemini-2.0-flash-lite-001'; } function clampConfidence(value, fallback = 0.5) { @@ -108,12 +121,89 @@ function asReasonList(reasons, fallback) { return [fallback]; } -function providerTimeoutMs() { - return Number.parseInt(process.env.VERIFICATION_PROVIDER_TIMEOUT_MS || '8000', 10); +function normalizeMachineStatus(status) { + if ( + status === VerificationStatus.AUTO_PASS + || status === VerificationStatus.AUTO_FAIL + || status === VerificationStatus.NEEDS_REVIEW + ) { + return status; + } + return VerificationStatus.NEEDS_REVIEW; } -function attireModel() { - return process.env.VERIFICATION_ATTIRE_MODEL || 'gemini-2.0-flash-lite-001'; +function toPublicJob(row) { + if (!row) return null; + return { + verificationId: row.id, + type: row.type, + subjectType: row.subject_type, + subjectId: row.subject_id, + fileUri: row.file_uri, + status: row.status, + confidence: row.confidence == null ? null : Number(row.confidence), + reasons: Array.isArray(row.reasons) ? row.reasons : [], + extracted: row.extracted || {}, + provider: row.provider_name + ? { + name: row.provider_name, + reference: row.provider_reference || null, + } + : null, + review: row.review || {}, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function assertAccess(row, actorUid) { + if (accessMode() === 'authenticated') { + return; + } + if (row.owner_user_id !== actorUid) { + throw new AppError('FORBIDDEN', 'Not allowed to access this verification', 403); + } +} + +async function loadJob(verificationId) { + const result = await query( + ` + SELECT * + FROM verification_jobs + WHERE id = $1 + `, + [verificationId] + ); + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Verification not found', 404, { + verificationId, + }); + } + return result.rows[0]; +} + +async function appendVerificationEvent(client, { + verificationJobId, + fromStatus, + toStatus, + actorType, + actorId, + details = {}, +}) { + await client.query( + ` + INSERT INTO verification_events ( + verification_job_id, + from_status, + to_status, + actor_type, + actor_id, + details + ) + VALUES ($1, $2, $3, $4, $5, $6::jsonb) + `, + [verificationJobId, fromStatus, toStatus, actorType, actorId, JSON.stringify(details)] + ); } async function runAttireChecks(job) { @@ -258,47 +348,26 @@ async function runThirdPartyChecks(job, type) { signal: controller.signal, }); - const bodyText = await response.text(); - let body = {}; - try { - body = bodyText ? JSON.parse(bodyText) : {}; - } catch { - body = {}; - } - + const payload = await response.json().catch(() => ({})); if (!response.ok) { - return { - status: VerificationStatus.NEEDS_REVIEW, - confidence: 0.35, - reasons: [`${provider.name} returned ${response.status}`], - extracted: {}, - provider: { - name: provider.name, - reference: body?.reference || null, - }, - }; + throw new Error(payload?.error || payload?.message || `${provider.name} failed`); } return { - status: normalizeMachineStatus(body.status), - confidence: clampConfidence(body.confidence, 0.6), - reasons: asReasonList(body.reasons, `${provider.name} completed check`), - extracted: body.extracted || {}, + status: normalizeMachineStatus(payload.status), + confidence: clampConfidence(payload.confidence, 0.6), + reasons: asReasonList(payload.reasons, `${provider.name} completed`), + extracted: payload.extracted || {}, provider: { name: provider.name, - reference: body.reference || null, + reference: payload.reference || null, }, }; } catch (error) { - const isAbort = error?.name === 'AbortError'; return { status: VerificationStatus.NEEDS_REVIEW, - confidence: 0.3, - reasons: [ - isAbort - ? `${provider.name} timeout, manual review required` - : `${provider.name} unavailable, manual review required`, - ], + confidence: 0.35, + reasons: [error?.message || `${provider.name} unavailable`], extracted: {}, provider: { name: provider.name, @@ -310,201 +379,462 @@ async function runThirdPartyChecks(job, type) { } } -async function runMachineChecks(job) { - if (job.type === 'attire') { - return runAttireChecks(job); - } +async function processVerificationJob(verificationId) { + const startedJob = await withTransaction(async (client) => { + const result = await client.query( + ` + SELECT * + FROM verification_jobs + WHERE id = $1 + FOR UPDATE + `, + [verificationId] + ); - if (job.type === 'government_id') { - return runThirdPartyChecks(job, 'government_id'); - } + if (result.rowCount === 0) { + return null; + } - return runThirdPartyChecks(job, 'certification'); -} + const job = result.rows[0]; + if (job.status !== VerificationStatus.PENDING) { + return null; + } -async function processVerificationJob(id) { - const job = requireJob(id); - if (job.status !== VerificationStatus.PENDING) { + await client.query( + ` + UPDATE verification_jobs + SET status = $2, + updated_at = NOW() + WHERE id = $1 + `, + [verificationId, VerificationStatus.PROCESSING] + ); + + await appendVerificationEvent(client, { + verificationJobId: verificationId, + fromStatus: job.status, + toStatus: VerificationStatus.PROCESSING, + actorType: 'worker', + actorId: 'verification-worker', + }); + + return { + id: verificationId, + type: job.type, + fileUri: job.file_uri, + subjectType: job.subject_type, + subjectId: job.subject_id, + rules: job.metadata?.rules || {}, + metadata: job.metadata || {}, + }; + }); + + if (!startedJob) { return; } - const beforeProcessing = job.status; - job.status = VerificationStatus.PROCESSING; - job.updatedAt = nowIso(); - job.events.push( - eventRecord({ - fromStatus: beforeProcessing, - toStatus: VerificationStatus.PROCESSING, - actorType: 'system', - actorId: 'verification-worker', - }) - ); - try { - const outcome = await runMachineChecks(job); - if (!MACHINE_TERMINAL_STATUSES.has(outcome.status)) { - throw new Error(`Invalid machine outcome status: ${outcome.status}`); - } - const fromStatus = job.status; - job.status = outcome.status; - job.confidence = outcome.confidence; - job.reasons = outcome.reasons; - job.extracted = outcome.extracted; - job.provider = outcome.provider; - job.updatedAt = nowIso(); - job.events.push( - eventRecord({ - fromStatus, - toStatus: job.status, - actorType: 'system', + const result = startedJob.type === 'attire' + ? await runAttireChecks(startedJob) + : await runThirdPartyChecks(startedJob, startedJob.type); + + await withTransaction(async (client) => { + await client.query( + ` + UPDATE verification_jobs + SET status = $2, + confidence = $3, + reasons = $4::jsonb, + extracted = $5::jsonb, + provider_name = $6, + provider_reference = $7, + updated_at = NOW() + WHERE id = $1 + `, + [ + verificationId, + result.status, + result.confidence, + JSON.stringify(result.reasons || []), + JSON.stringify(result.extracted || {}), + result.provider?.name || null, + result.provider?.reference || null, + ] + ); + + await appendVerificationEvent(client, { + verificationJobId: verificationId, + fromStatus: VerificationStatus.PROCESSING, + toStatus: result.status, + actorType: 'worker', actorId: 'verification-worker', details: { - confidence: job.confidence, - reasons: job.reasons, - provider: job.provider, + confidence: result.confidence, }, - }) - ); + }); + }); } catch (error) { - const fromStatus = job.status; - job.status = VerificationStatus.ERROR; - job.confidence = null; - job.reasons = [error?.message || 'Verification processing failed']; - job.extracted = {}; - job.provider = { - name: 'verification-worker', - reference: null, - }; - job.updatedAt = nowIso(); - job.events.push( - eventRecord({ - fromStatus, + await withTransaction(async (client) => { + await client.query( + ` + UPDATE verification_jobs + SET status = $2, + reasons = $3::jsonb, + provider_name = 'verification-worker', + provider_reference = $4, + updated_at = NOW() + WHERE id = $1 + `, + [ + verificationId, + VerificationStatus.ERROR, + JSON.stringify([error?.message || 'Verification processing failed']), + `error:${error?.code || 'unknown'}`, + ] + ); + + await appendVerificationEvent(client, { + verificationJobId: verificationId, + fromStatus: VerificationStatus.PROCESSING, toStatus: VerificationStatus.ERROR, - actorType: 'system', + actorType: 'worker', actorId: 'verification-worker', details: { error: error?.message || 'Verification processing failed', }, - }) - ); - } -} - -function queueVerificationProcessing(id) { - setTimeout(() => { - processVerificationJob(id).catch(() => {}); - }, 0); -} - -export function createVerificationJob({ actorUid, payload }) { - const now = nowIso(); - const id = `ver_${crypto.randomUUID()}`; - const job = { - id, - type: payload.type, - subjectType: payload.subjectType || null, - subjectId: payload.subjectId || null, - ownerUid: actorUid, - fileUri: payload.fileUri, - rules: payload.rules || {}, - metadata: payload.metadata || {}, - status: VerificationStatus.PENDING, - confidence: null, - reasons: [], - extracted: {}, - provider: null, - review: null, - createdAt: now, - updatedAt: now, - events: [ - eventRecord({ - fromStatus: null, - toStatus: VerificationStatus.PENDING, - actorType: 'system', - actorId: actorUid, - }), - ], - }; - jobs.set(id, job); - queueVerificationProcessing(id); - return toPublicJob(job); -} - -export function getVerificationJob(verificationId, actorUid) { - const job = requireJob(verificationId); - assertAccess(job, actorUid); - return toPublicJob(job); -} - -export function reviewVerificationJob(verificationId, actorUid, review) { - const job = requireJob(verificationId); - assertAccess(job, actorUid); - - if (HUMAN_TERMINAL_STATUSES.has(job.status)) { - throw new AppError('CONFLICT', 'Verification already finalized', 409, { - verificationId, - status: job.status, + }); }); } +} - const fromStatus = job.status; - job.status = review.decision; - job.review = { - decision: review.decision, - reviewedBy: actorUid, - reviewedAt: nowIso(), - note: review.note || '', - reasonCode: review.reasonCode || 'MANUAL_REVIEW', - }; - job.updatedAt = nowIso(); - job.events.push( - eventRecord({ - fromStatus, - toStatus: job.status, +function queueVerificationProcessing(verificationId) { + setImmediate(() => { + const worker = useMemoryStore() ? processVerificationJobInMemory : processVerificationJob; + worker(verificationId).catch(() => {}); + }); +} + +export async function createVerificationJob({ actorUid, payload }) { + if (useMemoryStore()) { + const timestamp = new Date().toISOString(); + const created = { + id: nextVerificationId(), + tenant_id: null, + staff_id: null, + owner_user_id: actorUid, + type: payload.type, + subject_type: payload.subjectType || null, + subject_id: payload.subjectId || null, + file_uri: payload.fileUri, + status: VerificationStatus.PENDING, + confidence: null, + reasons: [], + extracted: {}, + provider_name: null, + provider_reference: null, + review: {}, + metadata: { + ...(payload.metadata || {}), + rules: payload.rules || {}, + }, + created_at: timestamp, + updated_at: timestamp, + }; + memoryVerificationJobs.set(created.id, created); + queueVerificationProcessing(created.id); + return toPublicJob(created); + } + + const context = await requireTenantContext(actorUid); + const created = await withTransaction(async (client) => { + const result = await client.query( + ` + INSERT INTO verification_jobs ( + tenant_id, + staff_id, + document_id, + owner_user_id, + type, + subject_type, + subject_id, + file_uri, + status, + reasons, + extracted, + review, + metadata + ) + VALUES ( + $1, + $2, + NULL, + $3, + $4, + $5, + $6, + $7, + 'PENDING', + '[]'::jsonb, + '{}'::jsonb, + '{}'::jsonb, + $8::jsonb + ) + RETURNING * + `, + [ + context.tenant.tenantId, + context.staff?.staffId || null, + actorUid, + payload.type, + payload.subjectType || null, + payload.subjectId || null, + payload.fileUri, + JSON.stringify({ + ...(payload.metadata || {}), + rules: payload.rules || {}, + }), + ] + ); + + await appendVerificationEvent(client, { + verificationJobId: result.rows[0].id, + fromStatus: null, + toStatus: VerificationStatus.PENDING, + actorType: 'system', + actorId: actorUid, + }); + + return result.rows[0]; + }); + + queueVerificationProcessing(created.id); + return toPublicJob(created); +} + +export async function getVerificationJob(verificationId, actorUid) { + if (useMemoryStore()) { + const job = loadMemoryJob(verificationId); + assertAccess(job, actorUid); + return toPublicJob(job); + } + + const job = await loadJob(verificationId); + assertAccess(job, actorUid); + return toPublicJob(job); +} + +export async function reviewVerificationJob(verificationId, actorUid, review) { + if (useMemoryStore()) { + const job = loadMemoryJob(verificationId); + assertAccess(job, actorUid); + if (HUMAN_TERMINAL_STATUSES.has(job.status)) { + throw new AppError('CONFLICT', 'Verification already finalized', 409, { + verificationId, + status: job.status, + }); + } + + const reviewPayload = { + decision: review.decision, + reviewedBy: actorUid, + reviewedAt: new Date().toISOString(), + note: review.note || '', + reasonCode: review.reasonCode || 'MANUAL_REVIEW', + }; + + const updated = { + ...job, + status: review.decision, + review: reviewPayload, + updated_at: new Date().toISOString(), + }; + memoryVerificationJobs.set(verificationId, updated); + return toPublicJob(updated); + } + + const context = await requireTenantContext(actorUid); + const updated = await withTransaction(async (client) => { + const result = await client.query( + ` + SELECT * + FROM verification_jobs + WHERE id = $1 + FOR UPDATE + `, + [verificationId] + ); + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Verification not found', 404, { verificationId }); + } + + const job = result.rows[0]; + assertAccess(job, actorUid); + if (HUMAN_TERMINAL_STATUSES.has(job.status)) { + throw new AppError('CONFLICT', 'Verification already finalized', 409, { + verificationId, + status: job.status, + }); + } + + const reviewPayload = { + decision: review.decision, + reviewedBy: actorUid, + reviewedAt: new Date().toISOString(), + note: review.note || '', + reasonCode: review.reasonCode || 'MANUAL_REVIEW', + }; + + await client.query( + ` + UPDATE verification_jobs + SET status = $2, + review = $3::jsonb, + updated_at = NOW() + WHERE id = $1 + `, + [verificationId, review.decision, JSON.stringify(reviewPayload)] + ); + + await client.query( + ` + INSERT INTO verification_reviews ( + verification_job_id, + reviewer_user_id, + decision, + note, + reason_code + ) + VALUES ($1, $2, $3, $4, $5) + `, + [verificationId, actorUid, review.decision, review.note || null, review.reasonCode || 'MANUAL_REVIEW'] + ); + + await appendVerificationEvent(client, { + verificationJobId: verificationId, + fromStatus: job.status, + toStatus: review.decision, actorType: 'reviewer', actorId: actorUid, details: { - reasonCode: job.review.reasonCode, + reasonCode: review.reasonCode || 'MANUAL_REVIEW', }, - }) - ); + }); - return toPublicJob(job); + return { + ...job, + status: review.decision, + review: reviewPayload, + updated_at: new Date().toISOString(), + }; + }); + + void context; + return toPublicJob(updated); } -export function retryVerificationJob(verificationId, actorUid) { - const job = requireJob(verificationId); - assertAccess(job, actorUid); +export async function retryVerificationJob(verificationId, actorUid) { + if (useMemoryStore()) { + const job = loadMemoryJob(verificationId); + assertAccess(job, actorUid); + if (job.status === VerificationStatus.PROCESSING) { + throw new AppError('CONFLICT', 'Cannot retry while verification is processing', 409, { + verificationId, + }); + } - if (job.status === VerificationStatus.PROCESSING) { - throw new AppError('CONFLICT', 'Cannot retry while verification is processing', 409, { - verificationId, - }); + const updated = { + ...job, + status: VerificationStatus.PENDING, + confidence: null, + reasons: [], + extracted: {}, + provider_name: null, + provider_reference: null, + review: {}, + updated_at: new Date().toISOString(), + }; + memoryVerificationJobs.set(verificationId, updated); + queueVerificationProcessing(verificationId); + return toPublicJob(updated); } - const fromStatus = job.status; - job.status = VerificationStatus.PENDING; - job.confidence = null; - job.reasons = []; - job.extracted = {}; - job.provider = null; - job.review = null; - job.updatedAt = nowIso(); - job.events.push( - eventRecord({ - fromStatus, + const updated = await withTransaction(async (client) => { + const result = await client.query( + ` + SELECT * + FROM verification_jobs + WHERE id = $1 + FOR UPDATE + `, + [verificationId] + ); + + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Verification not found', 404, { verificationId }); + } + + const job = result.rows[0]; + assertAccess(job, actorUid); + if (job.status === VerificationStatus.PROCESSING) { + throw new AppError('CONFLICT', 'Cannot retry while verification is processing', 409, { + verificationId, + }); + } + + await client.query( + ` + UPDATE verification_jobs + SET status = $2, + confidence = NULL, + reasons = '[]'::jsonb, + extracted = '{}'::jsonb, + provider_name = NULL, + provider_reference = NULL, + review = '{}'::jsonb, + updated_at = NOW() + WHERE id = $1 + `, + [verificationId, VerificationStatus.PENDING] + ); + + await appendVerificationEvent(client, { + verificationJobId: verificationId, + fromStatus: job.status, toStatus: VerificationStatus.PENDING, actorType: 'reviewer', actorId: actorUid, details: { retried: true, }, - }) - ); + }); + + return { + ...job, + status: VerificationStatus.PENDING, + confidence: null, + reasons: [], + extracted: {}, + provider_name: null, + provider_reference: null, + review: {}, + updated_at: new Date().toISOString(), + }; + }); + queueVerificationProcessing(verificationId); - return toPublicJob(job); + return toPublicJob(updated); } -export function __resetVerificationJobsForTests() { - jobs.clear(); +export async function __resetVerificationJobsForTests() { + if (process.env.NODE_ENV !== 'test' && process.env.AUTH_BYPASS !== 'true') { + return; + } + memoryVerificationJobs.clear(); + try { + await query('DELETE FROM verification_reviews'); + await query('DELETE FROM verification_events'); + await query('DELETE FROM verification_jobs'); + } catch { + // Intentionally ignore when tests run without a configured database. + } } diff --git a/backend/core-api/test/app.test.js b/backend/core-api/test/app.test.js index c3e50de5..d6613a07 100644 --- a/backend/core-api/test/app.test.js +++ b/backend/core-api/test/app.test.js @@ -5,7 +5,7 @@ import { createApp } from '../src/app.js'; import { __resetLlmRateLimitForTests } from '../src/services/llm-rate-limit.js'; import { __resetVerificationJobsForTests } from '../src/services/verification-jobs.js'; -beforeEach(() => { +beforeEach(async () => { process.env.AUTH_BYPASS = 'true'; process.env.LLM_MOCK = 'true'; process.env.SIGNED_URL_MOCK = 'true'; @@ -15,8 +15,9 @@ beforeEach(() => { process.env.VERIFICATION_REQUIRE_FILE_EXISTS = 'false'; process.env.VERIFICATION_ACCESS_MODE = 'authenticated'; process.env.VERIFICATION_ATTIRE_PROVIDER = 'mock'; + process.env.VERIFICATION_STORE = 'memory'; __resetLlmRateLimitForTests(); - __resetVerificationJobsForTests(); + await __resetVerificationJobsForTests(); }); async function waitForMachineStatus(app, verificationId, maxAttempts = 30) { @@ -49,6 +50,22 @@ test('GET /healthz returns healthy response', async () => { assert.equal(typeof res.headers['x-request-id'], 'string'); }); +test('GET /readyz reports database not configured when env is absent', async () => { + delete process.env.DATABASE_URL; + delete process.env.DB_HOST; + delete process.env.DB_NAME; + delete process.env.DB_USER; + delete process.env.DB_PASSWORD; + delete process.env.INSTANCE_CONNECTION_NAME; + delete process.env.VERIFICATION_STORE; + + const app = createApp(); + const res = await request(app).get('/readyz'); + + assert.equal(res.status, 503); + assert.equal(res.body.status, 'DATABASE_NOT_CONFIGURED'); +}); + test('POST /core/create-signed-url requires auth', async () => { process.env.AUTH_BYPASS = 'false'; const app = createApp(); diff --git a/backend/query-api/src/data/faqs.js b/backend/query-api/src/data/faqs.js new file mode 100644 index 00000000..f12a8906 --- /dev/null +++ b/backend/query-api/src/data/faqs.js @@ -0,0 +1,41 @@ +export const FAQ_CATEGORIES = [ + { + category: 'Getting Started', + items: [ + { + question: 'How do I complete my worker profile?', + answer: 'Finish your personal info, preferred locations, experience, emergency contact, attire, and tax forms so shift applications and clock-in become available.', + }, + { + question: 'Why can I not apply to shifts yet?', + answer: 'The worker profile must be complete before the platform allows applications and shift acceptance. Missing sections are returned by the profile completion endpoints.', + }, + ], + }, + { + category: 'Shifts And Attendance', + items: [ + { + question: 'How does clock-in work?', + answer: 'Clock-in validates that you are assigned to the shift, near the configured hub geofence, and using the expected clock-in source such as near-field communication when required.', + }, + { + question: 'What happens if I request a swap?', + answer: 'The assignment moves to swap-requested status so operations can refill the shift while keeping an audit trail of the original assignment.', + }, + ], + }, + { + category: 'Payments And Compliance', + items: [ + { + question: 'When do I see my earnings?', + answer: 'Completed and processed time records appear in the worker payments summary, history, and time-card screens after attendance closes and payment processing runs.', + }, + { + question: 'How are documents and certificates verified?', + answer: 'Uploads create verification jobs that run automatic checks first and then allow manual review when confidence is low or a provider is unavailable.', + }, + ], + }, +]; diff --git a/backend/query-api/src/routes/mobile.js b/backend/query-api/src/routes/mobile.js index 07674ea7..5f4ce613 100644 --- a/backend/query-api/src/routes/mobile.js +++ b/backend/query-api/src/routes/mobile.js @@ -1,32 +1,47 @@ import { Router } from 'express'; import { requireAuth, requirePolicy } from '../middleware/auth.js'; import { + getCoverageReport, getClientDashboard, getClientSession, getCoverageStats, getCurrentAttendanceStatus, getCurrentBill, + getDailyOpsReport, getPaymentChart, getPaymentsSummary, getPersonalInfo, + getPerformanceReport, getProfileSectionsStatus, + getPrivacySettings, + getForecastReport, + getNoShowReport, + getOrderReorderPreview, + getReportSummary, getSavings, getStaffDashboard, getStaffProfileCompletion, getStaffSession, getStaffShiftDetail, + listAttireChecklist, listAssignedShifts, listBusinessAccounts, + listBusinessTeamMembers, listCancelledShifts, listCertificates, listCostCenters, + listCoreTeam, listCoverageByDate, listCompletedShifts, + listEmergencyContacts, + listFaqCategories, listHubManagers, listHubs, listIndustries, listInvoiceHistory, listOpenShifts, + listTaxForms, + listTimeCardEntries, listOrderItemsByDateRange, listPaymentsHistory, listPendingAssignments, @@ -40,37 +55,55 @@ import { listTodayShifts, listVendorRoles, listVendors, + searchFaqs, getSpendBreakdown, + getSpendReport, } from '../services/mobile-query-service.js'; const defaultQueryService = { getClientDashboard, getClientSession, + getCoverageReport, getCoverageStats, getCurrentAttendanceStatus, getCurrentBill, + getDailyOpsReport, getPaymentChart, getPaymentsSummary, getPersonalInfo, + getPerformanceReport, getProfileSectionsStatus, + getPrivacySettings, + getForecastReport, + getNoShowReport, + getOrderReorderPreview, + getReportSummary, getSavings, getSpendBreakdown, + getSpendReport, getStaffDashboard, getStaffProfileCompletion, getStaffSession, getStaffShiftDetail, + listAttireChecklist, listAssignedShifts, listBusinessAccounts, + listBusinessTeamMembers, listCancelledShifts, listCertificates, listCostCenters, + listCoreTeam, listCoverageByDate, listCompletedShifts, + listEmergencyContacts, + listFaqCategories, listHubManagers, listHubs, listIndustries, listInvoiceHistory, listOpenShifts, + listTaxForms, + listTimeCardEntries, listOrderItemsByDateRange, listPaymentsHistory, listPendingAssignments, @@ -84,6 +117,7 @@ const defaultQueryService = { listTodayShifts, listVendorRoles, listVendors, + searchFaqs, }; function requireQueryParam(name, value) { @@ -199,6 +233,15 @@ export function createMobileQueryRouter(queryService = defaultQueryService) { } }); + router.get('/client/coverage/core-team', requireAuth, requirePolicy('coverage.read', 'coverage'), async (req, res, next) => { + try { + const items = await queryService.listCoreTeam(req.actor.uid); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + router.get('/client/hubs', requireAuth, requirePolicy('hubs.read', 'hub'), async (req, res, next) => { try { const items = await queryService.listHubs(req.actor.uid); @@ -244,6 +287,15 @@ export function createMobileQueryRouter(queryService = defaultQueryService) { } }); + router.get('/client/team-members', requireAuth, requirePolicy('hubs.read', 'hub'), async (req, res, next) => { + try { + const items = await queryService.listBusinessTeamMembers(req.actor.uid); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + router.get('/client/orders/view', requireAuth, requirePolicy('orders.read', 'order'), async (req, res, next) => { try { const items = await queryService.listOrderItemsByDateRange(req.actor.uid, req.query); @@ -253,6 +305,78 @@ export function createMobileQueryRouter(queryService = defaultQueryService) { } }); + router.get('/client/orders/:orderId/reorder-preview', requireAuth, requirePolicy('orders.read', 'order'), async (req, res, next) => { + try { + const data = await queryService.getOrderReorderPreview(req.actor.uid, req.params.orderId); + return res.status(200).json({ ...data, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/client/reports/summary', requireAuth, requirePolicy('reports.read', 'report'), async (req, res, next) => { + try { + const data = await queryService.getReportSummary(req.actor.uid, req.query); + return res.status(200).json({ ...data, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/client/reports/daily-ops', requireAuth, requirePolicy('reports.read', 'report'), async (req, res, next) => { + try { + const data = await queryService.getDailyOpsReport(req.actor.uid, { date: requireQueryParam('date', req.query.date) }); + return res.status(200).json({ ...data, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/client/reports/spend', requireAuth, requirePolicy('reports.read', 'report'), async (req, res, next) => { + try { + const data = await queryService.getSpendReport(req.actor.uid, req.query); + return res.status(200).json({ ...data, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/client/reports/coverage', requireAuth, requirePolicy('reports.read', 'report'), async (req, res, next) => { + try { + const data = await queryService.getCoverageReport(req.actor.uid, req.query); + return res.status(200).json({ ...data, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/client/reports/forecast', requireAuth, requirePolicy('reports.read', 'report'), async (req, res, next) => { + try { + const data = await queryService.getForecastReport(req.actor.uid, req.query); + return res.status(200).json({ ...data, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/client/reports/performance', requireAuth, requirePolicy('reports.read', 'report'), async (req, res, next) => { + try { + const data = await queryService.getPerformanceReport(req.actor.uid, req.query); + return res.status(200).json({ ...data, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/client/reports/no-show', requireAuth, requirePolicy('reports.read', 'report'), async (req, res, next) => { + try { + const data = await queryService.getNoShowReport(req.actor.uid, req.query); + return res.status(200).json({ ...data, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + router.get('/staff/session', requireAuth, requirePolicy('staff.session.read', 'session'), async (req, res, next) => { try { const data = await queryService.getStaffSession(req.actor.uid); @@ -433,6 +557,33 @@ export function createMobileQueryRouter(queryService = defaultQueryService) { } }); + router.get('/staff/profile/attire', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => { + try { + const items = await queryService.listAttireChecklist(req.actor.uid); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/staff/profile/tax-forms', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => { + try { + const items = await queryService.listTaxForms(req.actor.uid); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/staff/profile/emergency-contacts', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => { + try { + const items = await queryService.listEmergencyContacts(req.actor.uid); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + router.get('/staff/profile/certificates', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => { try { const items = await queryService.listCertificates(req.actor.uid); @@ -460,5 +611,41 @@ export function createMobileQueryRouter(queryService = defaultQueryService) { } }); + router.get('/staff/profile/time-card', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => { + try { + const items = await queryService.listTimeCardEntries(req.actor.uid, req.query); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/staff/profile/privacy', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => { + try { + const data = await queryService.getPrivacySettings(req.actor.uid); + return res.status(200).json({ ...data, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/staff/faqs', async (req, res, next) => { + try { + const items = await queryService.listFaqCategories(); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/staff/faqs/search', async (req, res, next) => { + try { + const items = await queryService.searchFaqs(req.query.q || ''); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + return router; } diff --git a/backend/query-api/src/services/db.js b/backend/query-api/src/services/db.js index 272d0e3b..a0af590b 100644 --- a/backend/query-api/src/services/db.js +++ b/backend/query-api/src/services/db.js @@ -1,4 +1,16 @@ -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; +} + +// Mobile/frontend routes expect numeric JSON values for database aggregates. +types.setTypeParser(types.builtins.INT8, parseNumericDatabaseValue); +types.setTypeParser(types.builtins.NUMERIC, parseNumericDatabaseValue); let pool; diff --git a/backend/query-api/src/services/mobile-query-service.js b/backend/query-api/src/services/mobile-query-service.js index 166bb331..cc1b17a7 100644 --- a/backend/query-api/src/services/mobile-query-service.js +++ b/backend/query-api/src/services/mobile-query-service.js @@ -1,4 +1,5 @@ import { AppError } from '../lib/errors.js'; +import { FAQ_CATEGORIES } from '../data/faqs.js'; import { query } from './db.js'; import { requireClientContext, requireStaffContext } from './actor-context.js'; @@ -45,6 +46,12 @@ function metadataArray(metadata, key) { return Array.isArray(value) ? value : []; } +function metadataBoolean(metadata, key, fallback = false) { + const value = metadata?.[key]; + if (typeof value === 'boolean') return value; + return fallback; +} + function getProfileCompletionFromMetadata(staffRow) { const metadata = staffRow?.metadata || {}; const [firstName, ...lastParts] = (staffRow?.fullName || '').trim().split(/\s+/); @@ -775,34 +782,73 @@ export async function listOpenShifts(actorUid, { limit, search } = {}) { const context = await requireStaffContext(actorUid); const result = await query( ` - SELECT - s.id AS "shiftId", - sr.id AS "roleId", - 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", - 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 - LEFT JOIN clock_points cp ON cp.id = s.clock_point_id - WHERE s.tenant_id = $1 - AND s.status = 'OPEN' - AND ($2::text IS NULL OR sr.role_name ILIKE '%' || $2 || '%' OR COALESCE(cp.label, s.location_name) ILIKE '%' || $2 || '%') - AND NOT EXISTS ( - SELECT 1 - FROM applications a - WHERE a.shift_role_id = sr.id - AND a.staff_id = $3 - AND a.status IN ('PENDING', 'CONFIRMED') - ) - AND sr.role_code = $4 - ORDER BY s.starts_at ASC + WITH open_roles AS ( + SELECT + s.id AS "shiftId", + sr.id AS "roleId", + 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", + 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 + LEFT JOIN clock_points cp ON cp.id = s.clock_point_id + WHERE s.tenant_id = $1 + AND s.status = 'OPEN' + AND sr.role_code = $4 + AND ($2::text IS NULL OR sr.role_name ILIKE '%' || $2 || '%' OR COALESCE(cp.label, s.location_name) ILIKE '%' || $2 || '%') + AND NOT EXISTS ( + SELECT 1 + FROM applications a + WHERE a.shift_role_id = sr.id + AND a.staff_id = $3 + AND a.status IN ('PENDING', 'CONFIRMED') + ) + ), + swap_roles AS ( + SELECT + s.id AS "shiftId", + sr.id AS "roleId", + 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", + COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType", + FALSE AS "instantBook", + 1::INTEGER AS "requiredWorkerCount" + 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 + LEFT JOIN clock_points cp ON cp.id = s.clock_point_id + WHERE a.tenant_id = $1 + AND a.status = 'SWAP_REQUESTED' + AND a.staff_id <> $3 + AND sr.role_code = $4 + AND ($2::text IS NULL OR sr.role_name ILIKE '%' || $2 || '%' OR COALESCE(cp.label, s.location_name) ILIKE '%' || $2 || '%') + AND NOT EXISTS ( + SELECT 1 + FROM applications app + WHERE app.shift_role_id = sr.id + AND app.staff_id = $3 + AND app.status IN ('PENDING', 'CONFIRMED') + ) + ) + SELECT * + FROM ( + SELECT * FROM open_roles + UNION ALL + SELECT * FROM swap_roles + ) items + ORDER BY "startTime" ASC LIMIT $5 `, [ @@ -987,17 +1033,21 @@ export async function listProfileDocuments(actorUid) { const result = await query( ` SELECT - sd.id AS "staffDocumentId", d.id AS "documentId", d.document_type AS "documentType", d.name, + sd.id AS "staffDocumentId", sd.file_uri AS "fileUri", - sd.status, - sd.expires_at AS "expiresAt" - FROM staff_documents sd - JOIN documents d ON d.id = sd.document_id - WHERE sd.tenant_id = $1 - AND sd.staff_id = $2 + COALESCE(sd.status, 'NOT_UPLOADED') AS status, + sd.expires_at AS "expiresAt", + sd.metadata + FROM documents d + LEFT JOIN staff_documents sd + ON sd.document_id = d.id + 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') ORDER BY d.name ASC `, [context.tenant.tenantId, context.staff.staffId] @@ -1012,10 +1062,14 @@ export async function listCertificates(actorUid) { SELECT id AS "certificateId", certificate_type AS "certificateType", + COALESCE(metadata->>'name', certificate_type) AS name, + file_uri AS "fileUri", + metadata->>'issuer' AS issuer, certificate_number AS "certificateNumber", issued_at AS "issuedAt", expires_at AS "expiresAt", - status + status, + metadata->>'verificationStatus' AS "verificationStatus" FROM certificates WHERE tenant_id = $1 AND staff_id = $2 @@ -1069,3 +1123,580 @@ export async function listStaffBenefits(actorUid) { ); return result.rows; } + +export async function listCoreTeam(actorUid) { + const context = await requireClientContext(actorUid); + const result = await query( + ` + SELECT + st.id AS "staffId", + st.full_name AS "fullName", + st.primary_role AS "primaryRole", + st.average_rating AS "averageRating", + st.rating_count AS "ratingCount", + TRUE AS favorite + FROM staff_favorites sf + JOIN staffs st ON st.id = sf.staff_id + WHERE sf.tenant_id = $1 + AND sf.business_id = $2 + ORDER BY st.average_rating DESC, st.full_name ASC + `, + [context.tenant.tenantId, context.business.businessId] + ); + return result.rows; +} + +export async function getOrderReorderPreview(actorUid, orderId) { + const context = await requireClientContext(actorUid); + const result = await query( + ` + SELECT + o.id AS "orderId", + o.title, + o.description, + o.starts_at AS "startsAt", + o.ends_at AS "endsAt", + o.location_name AS "locationName", + o.location_address AS "locationAddress", + o.metadata, + json_agg( + json_build_object( + 'shiftId', s.id, + 'shiftCode', s.shift_code, + 'title', s.title, + 'startsAt', s.starts_at, + 'endsAt', s.ends_at, + 'roles', ( + SELECT json_agg( + json_build_object( + 'roleId', sr.id, + 'roleCode', sr.role_code, + 'roleName', sr.role_name, + 'workersNeeded', sr.workers_needed, + 'payRateCents', sr.pay_rate_cents, + 'billRateCents', sr.bill_rate_cents + ) + ORDER BY sr.role_name ASC + ) + FROM shift_roles sr + WHERE sr.shift_id = s.id + ) + ) + ORDER BY s.starts_at ASC + ) AS shifts + FROM orders o + JOIN shifts s ON s.order_id = o.id + WHERE o.tenant_id = $1 + AND o.business_id = $2 + AND o.id = $3 + GROUP BY o.id + `, + [context.tenant.tenantId, context.business.businessId, orderId] + ); + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Order not found for reorder preview', 404, { orderId }); + } + return result.rows[0]; +} + +export async function listBusinessTeamMembers(actorUid) { + const context = await requireClientContext(actorUid); + const result = await query( + ` + SELECT + bm.id AS "businessMembershipId", + u.id AS "userId", + COALESCE(u.display_name, u.email) AS name, + u.email, + bm.business_role AS role + FROM business_memberships bm + JOIN users u ON u.id = bm.user_id + WHERE bm.tenant_id = $1 + AND bm.business_id = $2 + AND bm.membership_status = 'ACTIVE' + ORDER BY name ASC + `, + [context.tenant.tenantId, context.business.businessId] + ); + return result.rows; +} + +export async function getReportSummary(actorUid, { startDate, endDate }) { + const context = await requireClientContext(actorUid); + const range = parseDateRange(startDate, endDate, 30); + const [shifts, spend, performance, noShow] = await Promise.all([ + query( + ` + SELECT + COUNT(DISTINCT s.id)::INTEGER AS "totalShifts", + COALESCE(AVG( + CASE WHEN s.required_workers = 0 THEN 1 + ELSE LEAST(s.assigned_workers::numeric / s.required_workers, 1) + END + ), 0)::NUMERIC(8,4) AS "averageCoverage" + FROM shifts s + WHERE s.tenant_id = $1 + AND s.business_id = $2 + AND s.starts_at >= $3::timestamptz + AND s.starts_at <= $4::timestamptz + `, + [context.tenant.tenantId, context.business.businessId, range.start, range.end] + ), + query( + ` + SELECT COALESCE(SUM(total_cents), 0)::BIGINT AS "totalSpendCents" + FROM invoices + WHERE tenant_id = $1 + AND business_id = $2 + AND created_at >= $3::timestamptz + AND created_at <= $4::timestamptz + `, + [context.tenant.tenantId, context.business.businessId, range.start, range.end] + ), + query( + ` + SELECT COALESCE(AVG(rating), 0)::NUMERIC(8,4) AS "averagePerformanceScore" + FROM staff_reviews + WHERE tenant_id = $1 + AND business_id = $2 + AND created_at >= $3::timestamptz + AND created_at <= $4::timestamptz + `, + [context.tenant.tenantId, context.business.businessId, range.start, range.end] + ), + query( + ` + SELECT COUNT(*)::INTEGER AS "noShowCount" + FROM assignments + WHERE tenant_id = $1 + AND business_id = $2 + AND status = 'NO_SHOW' + AND updated_at >= $3::timestamptz + AND updated_at <= $4::timestamptz + `, + [context.tenant.tenantId, context.business.businessId, range.start, range.end] + ), + ]); + + return { + totalShifts: Number(shifts.rows[0]?.totalShifts || 0), + totalSpendCents: Number(spend.rows[0]?.totalSpendCents || 0), + averageCoveragePercentage: Math.round(Number(shifts.rows[0]?.averageCoverage || 0) * 100), + averagePerformanceScore: Number(performance.rows[0]?.averagePerformanceScore || 0), + noShowCount: Number(noShow.rows[0]?.noShowCount || 0), + forecastAccuracyPercentage: 90, + }; +} + +export async function getDailyOpsReport(actorUid, { date }) { + const context = await requireClientContext(actorUid); + const from = startOfDay(date).toISOString(); + const to = endOfDay(date).toISOString(); + const shifts = await listCoverageByDate(actorUid, { date }); + const totals = await query( + ` + SELECT + COUNT(DISTINCT s.id)::INTEGER AS "totalShifts", + COUNT(DISTINCT a.id)::INTEGER AS "totalWorkersDeployed", + COALESCE(SUM(ts.regular_minutes + ts.overtime_minutes), 0)::INTEGER AS "totalMinutesWorked", + COALESCE(AVG( + CASE + WHEN att.check_in_at IS NULL THEN 0 + WHEN att.check_in_at <= s.starts_at THEN 1 + ELSE 0 + END + ), 0)::NUMERIC(8,4) AS "onTimeArrivalRate" + FROM shifts s + LEFT JOIN assignments a ON a.shift_id = s.id + LEFT JOIN attendance_sessions att ON att.assignment_id = a.id + LEFT JOIN timesheets ts ON ts.assignment_id = a.id + WHERE s.tenant_id = $1 + AND s.business_id = $2 + AND s.starts_at >= $3::timestamptz + AND s.starts_at < $4::timestamptz + `, + [context.tenant.tenantId, context.business.businessId, from, to] + ); + return { + totalShifts: Number(totals.rows[0]?.totalShifts || 0), + totalWorkersDeployed: Number(totals.rows[0]?.totalWorkersDeployed || 0), + totalHoursWorked: Math.round(Number(totals.rows[0]?.totalMinutesWorked || 0) / 60), + onTimeArrivalPercentage: Math.round(Number(totals.rows[0]?.onTimeArrivalRate || 0) * 100), + shifts, + }; +} + +export async function getSpendReport(actorUid, { startDate, endDate, bucket = 'day' }) { + const context = await requireClientContext(actorUid); + const range = parseDateRange(startDate, endDate, 30); + const bucketExpr = bucket === 'week' ? 'week' : 'day'; + const [total, chart, breakdown] = await Promise.all([ + query( + ` + SELECT COALESCE(SUM(total_cents), 0)::BIGINT AS "totalSpendCents" + FROM invoices + WHERE tenant_id = $1 + AND business_id = $2 + AND created_at >= $3::timestamptz + AND created_at <= $4::timestamptz + `, + [context.tenant.tenantId, context.business.businessId, range.start, range.end] + ), + query( + ` + SELECT + date_trunc('${bucketExpr}', created_at) AS bucket, + COALESCE(SUM(total_cents), 0)::BIGINT AS "amountCents" + FROM invoices + WHERE tenant_id = $1 + AND business_id = $2 + AND created_at >= $3::timestamptz + AND created_at <= $4::timestamptz + GROUP BY 1 + ORDER BY 1 ASC + `, + [context.tenant.tenantId, context.business.businessId, range.start, range.end] + ), + getSpendBreakdown(actorUid, { startDate, endDate }), + ]); + return { + totalSpendCents: Number(total.rows[0]?.totalSpendCents || 0), + chart: chart.rows, + breakdown, + }; +} + +export async function getCoverageReport(actorUid, { startDate, endDate }) { + const context = await requireClientContext(actorUid); + const range = parseDateRange(startDate, endDate, 30); + const result = await query( + ` + WITH daily AS ( + SELECT + date_trunc('day', starts_at) AS day, + SUM(required_workers)::INTEGER AS needed, + SUM(assigned_workers)::INTEGER AS filled + FROM shifts + WHERE tenant_id = $1 + AND business_id = $2 + AND starts_at >= $3::timestamptz + AND starts_at <= $4::timestamptz + GROUP BY 1 + ) + SELECT + day, + needed, + filled, + CASE WHEN needed = 0 THEN 0 + ELSE ROUND((filled::numeric / needed) * 100, 2) + END AS "coveragePercentage" + FROM daily + ORDER BY day ASC + `, + [context.tenant.tenantId, context.business.businessId, range.start, range.end] + ); + const totals = result.rows.reduce((acc, row) => { + acc.neededWorkers += Number(row.needed || 0); + acc.filledWorkers += Number(row.filled || 0); + return acc; + }, { neededWorkers: 0, filledWorkers: 0 }); + return { + averageCoveragePercentage: totals.neededWorkers === 0 + ? 0 + : Math.round((totals.filledWorkers / totals.neededWorkers) * 100), + filledWorkers: totals.filledWorkers, + neededWorkers: totals.neededWorkers, + chart: result.rows, + }; +} + +export async function getForecastReport(actorUid, { startDate, endDate }) { + const context = await requireClientContext(actorUid); + const range = parseDateRange(startDate, endDate, 42); + const weekly = await query( + ` + SELECT + date_trunc('week', s.starts_at) AS week, + COUNT(DISTINCT s.id)::INTEGER AS "shiftCount", + COALESCE(SUM(sr.workers_needed * EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600), 0)::NUMERIC(12,2) AS "workerHours", + COALESCE(SUM(sr.bill_rate_cents * sr.workers_needed), 0)::BIGINT AS "forecastSpendCents" + FROM shifts s + JOIN shift_roles sr ON sr.shift_id = s.id + WHERE s.tenant_id = $1 + AND s.business_id = $2 + AND s.starts_at >= $3::timestamptz + AND s.starts_at <= $4::timestamptz + GROUP BY 1 + ORDER BY 1 ASC + `, + [context.tenant.tenantId, context.business.businessId, range.start, range.end] + ); + const totals = weekly.rows.reduce((acc, row) => { + acc.forecastSpendCents += Number(row.forecastSpendCents || 0); + acc.totalShifts += Number(row.shiftCount || 0); + acc.totalWorkerHours += Number(row.workerHours || 0); + return acc; + }, { forecastSpendCents: 0, totalShifts: 0, totalWorkerHours: 0 }); + return { + forecastSpendCents: totals.forecastSpendCents, + averageWeeklySpendCents: weekly.rows.length === 0 ? 0 : Math.round(totals.forecastSpendCents / weekly.rows.length), + totalShifts: totals.totalShifts, + totalWorkerHours: totals.totalWorkerHours, + weeks: weekly.rows.map((row) => ({ + ...row, + averageShiftCostCents: Number(row.shiftCount || 0) === 0 ? 0 : Math.round(Number(row.forecastSpendCents || 0) / Number(row.shiftCount || 0)), + })), + }; +} + +export async function getPerformanceReport(actorUid, { startDate, endDate }) { + const context = await requireClientContext(actorUid); + const range = parseDateRange(startDate, endDate, 30); + const totals = await query( + ` + WITH base AS ( + SELECT + COUNT(DISTINCT s.id)::INTEGER AS total_shifts, + COUNT(DISTINCT s.id) FILTER (WHERE s.assigned_workers >= s.required_workers)::INTEGER AS filled_shifts, + COUNT(DISTINCT s.id) FILTER (WHERE s.status IN ('COMPLETED', 'ACTIVE'))::INTEGER AS completed_shifts, + COUNT(DISTINCT a.id) FILTER ( + WHERE att.check_in_at IS NOT NULL AND att.check_in_at <= s.starts_at + )::INTEGER AS on_time_assignments, + COUNT(DISTINCT a.id)::INTEGER AS total_assignments, + COUNT(DISTINCT a.id) FILTER (WHERE a.status = 'NO_SHOW')::INTEGER AS no_show_assignments + FROM shifts s + LEFT JOIN assignments a ON a.shift_id = s.id + LEFT JOIN attendance_sessions att ON att.assignment_id = a.id + WHERE s.tenant_id = $1 + AND s.business_id = $2 + AND s.starts_at >= $3::timestamptz + AND s.starts_at <= $4::timestamptz + ), + fill_times AS ( + SELECT COALESCE(AVG(EXTRACT(EPOCH FROM (a.assigned_at - s.created_at)) / 60), 0)::NUMERIC(12,2) AS avg_fill_minutes + FROM assignments a + JOIN shifts s ON s.id = a.shift_id + WHERE a.tenant_id = $1 + AND a.business_id = $2 + AND s.starts_at >= $3::timestamptz + AND s.starts_at <= $4::timestamptz + ), + reviews AS ( + SELECT COALESCE(AVG(rating), 0)::NUMERIC(8,4) AS avg_rating + FROM staff_reviews + WHERE tenant_id = $1 + AND business_id = $2 + AND created_at >= $3::timestamptz + AND created_at <= $4::timestamptz + ) + SELECT * + FROM base, fill_times, reviews + `, + [context.tenant.tenantId, context.business.businessId, range.start, range.end] + ); + const row = totals.rows[0] || {}; + const totalShifts = Number(row.total_shifts || 0); + const totalAssignments = Number(row.total_assignments || 0); + return { + averagePerformanceScore: Number(row.avg_rating || 0), + fillRatePercentage: totalShifts === 0 ? 0 : Math.round((Number(row.filled_shifts || 0) / totalShifts) * 100), + completionRatePercentage: totalShifts === 0 ? 0 : Math.round((Number(row.completed_shifts || 0) / totalShifts) * 100), + onTimeRatePercentage: totalAssignments === 0 ? 0 : Math.round((Number(row.on_time_assignments || 0) / totalAssignments) * 100), + averageFillTimeMinutes: Number(row.avg_fill_minutes || 0), + totalShiftsCovered: Number(row.completed_shifts || 0), + noShowRatePercentage: totalAssignments === 0 ? 0 : Math.round((Number(row.no_show_assignments || 0) / totalAssignments) * 100), + }; +} + +export async function getNoShowReport(actorUid, { startDate, endDate }) { + const context = await requireClientContext(actorUid); + const range = parseDateRange(startDate, endDate, 30); + const incidents = await query( + ` + SELECT + st.id AS "staffId", + st.full_name AS "staffName", + COUNT(*)::INTEGER AS "incidentCount", + json_agg( + json_build_object( + 'shiftId', s.id, + 'shiftTitle', s.title, + 'roleName', sr.role_name, + 'date', s.starts_at + ) + ORDER BY s.starts_at DESC + ) AS incidents + FROM assignments a + JOIN staffs st ON st.id = a.staff_id + JOIN shifts s ON s.id = a.shift_id + JOIN shift_roles sr ON sr.id = a.shift_role_id + WHERE a.tenant_id = $1 + AND a.business_id = $2 + AND a.status = 'NO_SHOW' + AND s.starts_at >= $3::timestamptz + AND s.starts_at <= $4::timestamptz + GROUP BY st.id + ORDER BY "incidentCount" DESC, "staffName" ASC + `, + [context.tenant.tenantId, context.business.businessId, range.start, range.end] + ); + const totalNoShowCount = incidents.rows.reduce((acc, row) => acc + Number(row.incidentCount || 0), 0); + const totalWorkers = incidents.rows.length; + const totalAssignments = await query( + ` + SELECT COUNT(*)::INTEGER AS total + FROM assignments + WHERE tenant_id = $1 + AND business_id = $2 + AND created_at >= $3::timestamptz + AND created_at <= $4::timestamptz + `, + [context.tenant.tenantId, context.business.businessId, range.start, range.end] + ); + return { + totalNoShowCount, + noShowRatePercentage: Number(totalAssignments.rows[0]?.total || 0) === 0 + ? 0 + : Math.round((totalNoShowCount / Number(totalAssignments.rows[0].total)) * 100), + workersWhoNoShowed: totalWorkers, + items: incidents.rows.map((row) => ({ + ...row, + riskStatus: Number(row.incidentCount || 0) >= 2 ? 'HIGH' : 'MEDIUM', + })), + }; +} + +export async function listEmergencyContacts(actorUid) { + const context = await requireStaffContext(actorUid); + const result = await query( + ` + SELECT + id AS "contactId", + full_name AS "fullName", + phone, + relationship_type AS "relationshipType", + is_primary AS "isPrimary" + FROM emergency_contacts + WHERE tenant_id = $1 + AND staff_id = $2 + ORDER BY is_primary DESC, created_at ASC + `, + [context.tenant.tenantId, context.staff.staffId] + ); + return result.rows; +} + +export async function listTaxForms(actorUid) { + const context = await requireStaffContext(actorUid); + const docs = ['I-9', 'W-4']; + const result = await query( + ` + SELECT + d.id AS "documentId", + d.name AS "formType", + sd.id AS "staffDocumentId", + COALESCE(sd.metadata->>'formStatus', 'NOT_STARTED') AS status, + COALESCE(sd.metadata->'fields', '{}'::jsonb) AS fields + FROM documents d + LEFT JOIN staff_documents sd + ON sd.document_id = d.id + AND sd.staff_id = $2 + AND sd.tenant_id = $1 + WHERE d.tenant_id = $1 + AND d.document_type = 'TAX_FORM' + AND d.name = ANY($3::text[]) + ORDER BY d.name ASC + `, + [context.tenant.tenantId, context.staff.staffId, docs] + ); + return result.rows; +} + +export async function listAttireChecklist(actorUid) { + const context = await requireStaffContext(actorUid); + const result = await query( + ` + SELECT + d.id AS "documentId", + d.name, + COALESCE(d.metadata->>'description', '') AS description, + COALESCE((d.metadata->>'required')::boolean, TRUE) AS mandatory, + sd.id AS "staffDocumentId", + sd.file_uri AS "photoUri", + COALESCE(sd.status, 'NOT_UPLOADED') AS status, + sd.metadata->>'verificationStatus' AS "verificationStatus" + FROM documents d + LEFT JOIN staff_documents sd + ON sd.document_id = d.id + AND sd.staff_id = $2 + AND sd.tenant_id = $1 + WHERE d.tenant_id = $1 + AND d.document_type = 'ATTIRE' + ORDER BY d.name ASC + `, + [context.tenant.tenantId, context.staff.staffId] + ); + return result.rows; +} + +export async function listTimeCardEntries(actorUid, { month, year }) { + const context = await requireStaffContext(actorUid); + const monthValue = Number.parseInt(`${month || new Date().getUTCMonth() + 1}`, 10); + const yearValue = Number.parseInt(`${year || new Date().getUTCFullYear()}`, 10); + const start = new Date(Date.UTC(yearValue, monthValue - 1, 1)); + const end = new Date(Date.UTC(yearValue, monthValue, 1)); + const result = await query( + ` + SELECT + s.starts_at::date AS date, + s.title AS "shiftName", + COALESCE(cp.label, s.location_name) AS location, + att.check_in_at AS "clockInAt", + att.check_out_at AS "clockOutAt", + COALESCE(ts.regular_minutes + ts.overtime_minutes, 0) AS "minutesWorked", + sr.pay_rate_cents AS "hourlyRateCents", + COALESCE(ts.gross_pay_cents, 0)::BIGINT AS "totalPayCents" + FROM assignments a + JOIN shifts s ON s.id = a.shift_id + LEFT JOIN shift_roles sr ON sr.id = a.shift_role_id + LEFT JOIN attendance_sessions att ON att.assignment_id = a.id + LEFT JOIN timesheets ts ON ts.assignment_id = a.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 ('CHECKED_OUT', 'COMPLETED') + ORDER BY s.starts_at DESC + `, + [context.tenant.tenantId, context.staff.staffId, start.toISOString(), end.toISOString()] + ); + return result.rows; +} + +export async function getPrivacySettings(actorUid) { + const context = await requireStaffContext(actorUid); + return { + profileVisible: metadataBoolean(context.staff.metadata || {}, 'profileVisible', true), + }; +} + +export async function listFaqCategories() { + return FAQ_CATEGORIES; +} + +export async function searchFaqs(queryText) { + const needle = `${queryText || ''}`.trim().toLowerCase(); + if (!needle) { + return FAQ_CATEGORIES; + } + return FAQ_CATEGORIES + .map((category) => ({ + category: category.category, + items: category.items.filter((item) => { + const haystack = `${item.question} ${item.answer}`.toLowerCase(); + return haystack.includes(needle); + }), + })) + .filter((category) => category.items.length > 0); +} diff --git a/backend/query-api/test/mobile-routes.test.js b/backend/query-api/test/mobile-routes.test.js index c42ad7df..92cf3ead 100644 --- a/backend/query-api/test/mobile-routes.test.js +++ b/backend/query-api/test/mobile-routes.test.js @@ -10,13 +10,21 @@ function createMobileQueryService() { getClientDashboard: async () => ({ businessName: 'Google Cafes' }), getClientSession: async () => ({ business: { businessId: 'b1' } }), getCoverageStats: async () => ({ totalCoveragePercentage: 100 }), + getCoverageReport: async () => ({ items: [{ shiftId: 'coverage-1' }] }), getCurrentAttendanceStatus: async () => ({ attendanceStatus: 'NOT_CLOCKED_IN' }), getCurrentBill: async () => ({ currentBillCents: 1000 }), + getDailyOpsReport: async () => ({ totals: { workedAssignments: 4 } }), + getForecastReport: async () => ({ totals: { projectedCoveragePercentage: 92 } }), + getNoShowReport: async () => ({ totals: { noShows: 1 } }), getPaymentChart: async () => ([{ amountCents: 100 }]), getPaymentsSummary: async () => ({ totalEarningsCents: 500 }), getPersonalInfo: async () => ({ firstName: 'Ana' }), + getPerformanceReport: async () => ({ totals: { averageRating: 4.8 } }), getProfileSectionsStatus: async () => ({ personalInfoCompleted: true }), + getPrivacySettings: async () => ({ profileVisibility: 'TEAM_ONLY' }), + getReportSummary: async () => ({ reportDate: '2026-03-13', totals: { orders: 3 } }), getSavings: async () => ({ savingsCents: 200 }), + getSpendReport: async () => ({ totals: { amountCents: 2000 } }), getSpendBreakdown: async () => ([{ category: 'Barista', amountCents: 1000 }]), getStaffDashboard: async () => ({ staffName: 'Ana Barista' }), getStaffProfileCompletion: async () => ({ completed: true }), @@ -28,25 +36,34 @@ function createMobileQueryService() { listCertificates: async () => ([{ certificateId: 'cert-1' }]), listCostCenters: async () => ([{ costCenterId: 'cc-1' }]), listCoverageByDate: async () => ([{ shiftId: 'coverage-1' }]), + listCoreTeam: async () => ([{ staffId: 'core-1' }]), listCompletedShifts: async () => ([{ shiftId: 'completed-1' }]), + listEmergencyContacts: async () => ([{ contactId: 'ec-1' }]), + listFaqCategories: async () => ([{ id: 'faq-1', title: 'Clock in' }]), listHubManagers: async () => ([{ managerId: 'm1' }]), listHubs: async () => ([{ hubId: 'hub-1' }]), listIndustries: async () => (['CATERING']), listInvoiceHistory: async () => ([{ invoiceId: 'inv-1' }]), listOpenShifts: async () => ([{ shiftId: 'open-1' }]), + getOrderReorderPreview: async () => ({ orderId: 'order-1', lines: 2 }), listOrderItemsByDateRange: async () => ([{ itemId: 'item-1' }]), listPaymentsHistory: async () => ([{ paymentId: 'pay-1' }]), listPendingAssignments: async () => ([{ assignmentId: 'asg-1' }]), listPendingInvoices: async () => ([{ invoiceId: 'pending-1' }]), listProfileDocuments: async () => ([{ staffDocumentId: 'doc-1' }]), listRecentReorders: async () => ([{ id: 'order-1' }]), + listBusinessTeamMembers: async () => ([{ userId: 'u-1' }]), listSkills: async () => (['BARISTA']), listStaffAvailability: async () => ([{ dayOfWeek: 1 }]), listStaffBankAccounts: async () => ([{ accountId: 'acc-2' }]), listStaffBenefits: async () => ([{ benefitId: 'benefit-1' }]), + listTaxForms: async () => ([{ formType: 'W4' }]), + listAttireChecklist: async () => ([{ documentId: 'attire-1' }]), + listTimeCardEntries: async () => ([{ entryId: 'tc-1' }]), listTodayShifts: async () => ([{ shiftId: 'today-1' }]), listVendorRoles: async () => ([{ roleId: 'role-1' }]), listVendors: async () => ([{ vendorId: 'vendor-1' }]), + searchFaqs: async () => ([{ id: 'faq-2', title: 'Payments' }]), }; } @@ -89,3 +106,43 @@ test('GET /query/staff/shifts/:shiftId returns injected shift detail', async () assert.equal(res.status, 200); assert.equal(res.body.shiftId, 'shift-1'); }); + +test('GET /query/client/reports/summary returns injected report summary', async () => { + const app = createApp({ mobileQueryService: createMobileQueryService() }); + const res = await request(app) + .get('/query/client/reports/summary?date=2026-03-13') + .set('Authorization', 'Bearer test-token'); + + assert.equal(res.status, 200); + assert.equal(res.body.totals.orders, 3); +}); + +test('GET /query/client/coverage/core-team returns injected core team list', async () => { + const app = createApp({ mobileQueryService: createMobileQueryService() }); + const res = await request(app) + .get('/query/client/coverage/core-team?date=2026-03-13') + .set('Authorization', 'Bearer test-token'); + + assert.equal(res.status, 200); + assert.equal(res.body.items[0].staffId, 'core-1'); +}); + +test('GET /query/staff/profile/tax-forms returns injected tax forms', async () => { + const app = createApp({ mobileQueryService: createMobileQueryService() }); + const res = await request(app) + .get('/query/staff/profile/tax-forms') + .set('Authorization', 'Bearer test-token'); + + assert.equal(res.status, 200); + assert.equal(res.body.items[0].formType, 'W4'); +}); + +test('GET /query/staff/faqs/search returns injected faq search results', async () => { + const app = createApp({ mobileQueryService: createMobileQueryService() }); + const res = await request(app) + .get('/query/staff/faqs/search?q=payments') + .set('Authorization', 'Bearer test-token'); + + assert.equal(res.status, 200); + assert.equal(res.body.items[0].title, 'Payments'); +}); diff --git a/backend/unified-api/scripts/ensure-v2-demo-users.mjs b/backend/unified-api/scripts/ensure-v2-demo-users.mjs new file mode 100644 index 00000000..d5ea6cc1 --- /dev/null +++ b/backend/unified-api/scripts/ensure-v2-demo-users.mjs @@ -0,0 +1,64 @@ +import { signInWithPassword, signUpWithPassword } from '../src/services/identity-toolkit.js'; + +const ownerEmail = process.env.V2_DEMO_OWNER_EMAIL || 'legendary.owner+v2@krowd.com'; +const staffEmail = process.env.V2_DEMO_STAFF_EMAIL || 'ana.barista+v2@krowd.com'; +const ownerPassword = process.env.V2_DEMO_OWNER_PASSWORD || 'Demo2026!'; +const staffPassword = process.env.V2_DEMO_STAFF_PASSWORD || 'Demo2026!'; + +async function ensureUser({ email, password, displayName }) { + try { + const signedIn = await signInWithPassword({ email, password }); + return { + uid: signedIn.localId, + email, + password, + created: false, + displayName, + }; + } catch (error) { + const message = error?.message || ''; + if (!message.includes('INVALID_LOGIN_CREDENTIALS') && !message.includes('EMAIL_NOT_FOUND')) { + throw error; + } + } + + try { + const signedUp = await signUpWithPassword({ email, password }); + return { + uid: signedUp.localId, + email, + password, + created: true, + displayName, + }; + } catch (error) { + const message = error?.message || ''; + if (message.includes('EMAIL_EXISTS')) { + throw new Error(`Firebase user ${email} exists but password does not match expected demo password.`); + } + throw error; + } +} + +async function main() { + const owner = await ensureUser({ + email: ownerEmail, + password: ownerPassword, + displayName: 'Legendary Demo Owner V2', + }); + + const staff = await ensureUser({ + email: staffEmail, + password: staffPassword, + displayName: 'Ana Barista V2', + }); + + // eslint-disable-next-line no-console + console.log(JSON.stringify({ owner, staff }, null, 2)); +} + +main().catch((error) => { + // eslint-disable-next-line no-console + console.error(error); + process.exit(1); +}); diff --git a/backend/unified-api/scripts/live-smoke-v2-unified.mjs b/backend/unified-api/scripts/live-smoke-v2-unified.mjs new file mode 100644 index 00000000..4652fd85 --- /dev/null +++ b/backend/unified-api/scripts/live-smoke-v2-unified.mjs @@ -0,0 +1,971 @@ +import assert from 'node:assert/strict'; +import { signInWithPassword } from '../src/services/identity-toolkit.js'; +import { V2DemoFixture as fixture } from '../../command-api/scripts/v2-demo-fixture.mjs'; + +const unifiedBaseUrl = process.env.UNIFIED_API_BASE_URL || 'https://krow-api-v2-e3g6witsvq-uc.a.run.app'; +const ownerEmail = process.env.V2_DEMO_OWNER_EMAIL || 'legendary.owner+v2@krowd.com'; +const staffEmail = process.env.V2_DEMO_STAFF_EMAIL || 'ana.barista+v2@krowd.com'; +const ownerPassword = process.env.V2_DEMO_OWNER_PASSWORD || 'Demo2026!'; +const staffPassword = process.env.V2_DEMO_STAFF_PASSWORD || 'Demo2026!'; + +function uniqueKey(prefix) { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +function isoDate(offsetDays = 0) { + const value = new Date(Date.now() + (offsetDays * 24 * 60 * 60 * 1000)); + return value.toISOString().slice(0, 10); +} + +function isoTimestamp(offsetHours = 0) { + return new Date(Date.now() + (offsetHours * 60 * 60 * 1000)).toISOString(); +} + +function logStep(step, payload) { + // eslint-disable-next-line no-console + console.log(`[unified-smoke-v2] ${step}: ${JSON.stringify(payload)}`); +} + +async function readJson(response) { + const text = await response.text(); + return text ? JSON.parse(text) : {}; +} + +async function apiCall(path, { + method = 'GET', + token, + idempotencyKey, + body, + expectedStatus = 200, +} = {}) { + const headers = {}; + if (token) headers.Authorization = `Bearer ${token}`; + if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey; + if (body !== undefined) headers['Content-Type'] = 'application/json'; + + const response = await fetch(`${unifiedBaseUrl}${path}`, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const payload = await readJson(response); + if (response.status !== expectedStatus) { + throw new Error(`${method} ${path} expected ${expectedStatus}, got ${response.status}: ${JSON.stringify(payload)}`); + } + return payload; +} + +async function uploadFile(path, token, { + filename, + contentType, + content, + fields = {}, + expectedStatus = 200, +}) { + const form = new FormData(); + for (const [key, value] of Object.entries(fields)) { + form.set(key, value); + } + form.set( + 'file', + new File([content], filename, { + type: contentType, + }) + ); + + const response = await fetch(`${unifiedBaseUrl}${path}`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + }, + body: form, + }); + const payload = await readJson(response); + if (response.status !== expectedStatus) { + throw new Error(`POST ${path} expected ${expectedStatus}, got ${response.status}: ${JSON.stringify(payload)}`); + } + return payload; +} + +async function signInClient() { + return apiCall('/auth/client/sign-in', { + method: 'POST', + body: { + email: ownerEmail, + password: ownerPassword, + }, + }); +} + +async function signInStaff() { + return signInWithPassword({ + email: staffEmail, + password: staffPassword, + }); +} + +async function main() { + const reportWindow = `startDate=${encodeURIComponent(isoTimestamp(-24 * 14))}&endDate=${encodeURIComponent(isoTimestamp(24 * 14))}`; + const ownerSession = await signInClient(); + const staffAuth = await signInStaff(); + + assert.ok(ownerSession.sessionToken); + assert.ok(staffAuth.idToken); + assert.equal(ownerSession.business.businessId, fixture.business.id); + logStep('auth.client.sign-in.ok', { + tenantId: ownerSession.tenant.tenantId, + businessId: ownerSession.business.businessId, + }); + logStep('auth.staff.password-sign-in.ok', { + uid: staffAuth.localId, + email: staffEmail, + }); + + const authSession = await apiCall('/auth/session', { + token: ownerSession.sessionToken, + }); + assert.equal(authSession.business.businessId, fixture.business.id); + logStep('auth.session.ok', authSession); + + const staffPhoneStart = await apiCall('/auth/staff/phone/start', { + method: 'POST', + body: { phoneNumber: fixture.staff.ana.phone }, + }); + assert.equal(staffPhoneStart.mode, 'CLIENT_FIREBASE_SDK'); + logStep('auth.staff.phone-start.ok', staffPhoneStart); + + const staffPhoneVerify = await apiCall('/auth/staff/phone/verify', { + method: 'POST', + body: { + mode: 'sign-in', + idToken: staffAuth.idToken, + }, + }); + assert.equal(staffPhoneVerify.staff.staffId, fixture.staff.ana.id); + logStep('auth.staff.phone-verify.ok', { + staffId: staffPhoneVerify.staff.staffId, + requiresProfileSetup: staffPhoneVerify.requiresProfileSetup, + }); + + const clientSession = await apiCall('/client/session', { + token: ownerSession.sessionToken, + }); + assert.equal(clientSession.business.businessId, fixture.business.id); + logStep('client.session.ok', clientSession); + + const clientDashboard = await apiCall('/client/dashboard', { + token: ownerSession.sessionToken, + }); + assert.equal(clientDashboard.businessId, fixture.business.id); + logStep('client.dashboard.ok', { + weeklySpendCents: clientDashboard.spending.weeklySpendCents, + openPositionsToday: clientDashboard.coverage.openPositionsToday, + }); + + const clientReorders = await apiCall('/client/reorders', { + token: ownerSession.sessionToken, + }); + assert.ok(Array.isArray(clientReorders.items)); + logStep('client.reorders.ok', { count: clientReorders.items.length }); + + const billingAccounts = await apiCall('/client/billing/accounts', { + token: ownerSession.sessionToken, + }); + assert.ok(Array.isArray(billingAccounts.items)); + logStep('client.billing.accounts.ok', { count: billingAccounts.items.length }); + + const pendingInvoices = await apiCall('/client/billing/invoices/pending', { + token: ownerSession.sessionToken, + }); + assert.ok(pendingInvoices.items.length >= 1); + const invoiceId = pendingInvoices.items[0].invoiceId; + logStep('client.billing.pending-invoices.ok', { count: pendingInvoices.items.length, invoiceId }); + + const invoiceHistory = await apiCall('/client/billing/invoices/history', { + token: ownerSession.sessionToken, + }); + assert.ok(Array.isArray(invoiceHistory.items)); + logStep('client.billing.invoice-history.ok', { count: invoiceHistory.items.length }); + + const currentBill = await apiCall('/client/billing/current-bill', { + token: ownerSession.sessionToken, + }); + assert.ok(typeof currentBill.currentBillCents === 'number'); + logStep('client.billing.current-bill.ok', currentBill); + + const savings = await apiCall('/client/billing/savings', { + token: ownerSession.sessionToken, + }); + assert.ok(typeof savings.savingsCents === 'number'); + logStep('client.billing.savings.ok', savings); + + const spendBreakdown = await apiCall('/client/billing/spend-breakdown?period=month', { + token: ownerSession.sessionToken, + }); + assert.ok(Array.isArray(spendBreakdown.items)); + logStep('client.billing.spend-breakdown.ok', { count: spendBreakdown.items.length }); + + const coverage = await apiCall(`/client/coverage?date=${isoDate(0)}`, { + token: ownerSession.sessionToken, + }); + assert.ok(Array.isArray(coverage.items)); + logStep('client.coverage.ok', { count: coverage.items.length }); + + const coverageStats = await apiCall(`/client/coverage/stats?date=${isoDate(0)}`, { + token: ownerSession.sessionToken, + }); + assert.ok(typeof coverageStats.totalCoveragePercentage === 'number'); + logStep('client.coverage.stats.ok', coverageStats); + + const coreTeam = await apiCall('/client/coverage/core-team', { + token: ownerSession.sessionToken, + }); + assert.ok(Array.isArray(coreTeam.items)); + logStep('client.coverage.core-team.ok', { count: coreTeam.items.length }); + + const hubs = await apiCall('/client/hubs', { + token: ownerSession.sessionToken, + }); + assert.ok(hubs.items.some((hub) => hub.hubId === fixture.clockPoint.id)); + logStep('client.hubs.ok', { count: hubs.items.length }); + + const costCenters = await apiCall('/client/cost-centers', { + token: ownerSession.sessionToken, + }); + assert.ok(costCenters.items.length >= 1); + logStep('client.cost-centers.ok', { count: costCenters.items.length }); + + const vendors = await apiCall('/client/vendors', { + token: ownerSession.sessionToken, + }); + assert.ok(vendors.items.length >= 1); + logStep('client.vendors.ok', { count: vendors.items.length }); + + const vendorRoles = await apiCall(`/client/vendors/${fixture.vendor.id}/roles`, { + token: ownerSession.sessionToken, + }); + assert.ok(vendorRoles.items.length >= 1); + logStep('client.vendor-roles.ok', { count: vendorRoles.items.length }); + + const hubManagers = await apiCall(`/client/hubs/${fixture.clockPoint.id}/managers`, { + token: ownerSession.sessionToken, + }); + assert.ok(Array.isArray(hubManagers.items)); + logStep('client.hub-managers.ok', { count: hubManagers.items.length }); + + const teamMembers = await apiCall('/client/team-members', { + token: ownerSession.sessionToken, + }); + assert.ok(Array.isArray(teamMembers.items)); + logStep('client.team-members.ok', { count: teamMembers.items.length }); + + const viewedOrders = await apiCall(`/client/orders/view?${reportWindow}`, { + token: ownerSession.sessionToken, + }); + assert.ok(Array.isArray(viewedOrders.items)); + logStep('client.orders.view.ok', { count: viewedOrders.items.length }); + + const reorderPreview = await apiCall(`/client/orders/${fixture.orders.completed.id}/reorder-preview`, { + token: ownerSession.sessionToken, + }); + assert.equal(reorderPreview.orderId, fixture.orders.completed.id); + logStep('client.orders.reorder-preview.ok', reorderPreview); + + const reportSummary = await apiCall(`/client/reports/summary?${reportWindow}`, { + token: ownerSession.sessionToken, + }); + assert.ok(typeof reportSummary.totalShifts === 'number'); + logStep('client.reports.summary.ok', reportSummary); + + const dailyOps = await apiCall(`/client/reports/daily-ops?date=${isoDate(0)}`, { + token: ownerSession.sessionToken, + }); + logStep('client.reports.daily-ops.ok', dailyOps); + + const spendReport = await apiCall(`/client/reports/spend?${reportWindow}`, { + token: ownerSession.sessionToken, + }); + logStep('client.reports.spend.ok', spendReport); + + const coverageReport = await apiCall(`/client/reports/coverage?${reportWindow}`, { + token: ownerSession.sessionToken, + }); + logStep('client.reports.coverage.ok', coverageReport); + + const forecastReport = await apiCall(`/client/reports/forecast?${reportWindow}`, { + token: ownerSession.sessionToken, + }); + logStep('client.reports.forecast.ok', forecastReport); + + const performanceReport = await apiCall(`/client/reports/performance?${reportWindow}`, { + token: ownerSession.sessionToken, + }); + logStep('client.reports.performance.ok', performanceReport); + + const noShowReport = await apiCall(`/client/reports/no-show?${reportWindow}`, { + token: ownerSession.sessionToken, + }); + logStep('client.reports.no-show.ok', noShowReport); + + const createdHub = await apiCall('/client/hubs', { + method: 'POST', + token: ownerSession.sessionToken, + idempotencyKey: uniqueKey('create-hub'), + body: { + name: `Smoke Hub ${Date.now()}`, + fullAddress: '500 Castro Street, Mountain View, CA', + latitude: 37.3925, + longitude: -122.0782, + city: 'Mountain View', + state: 'CA', + country: 'US', + zipCode: '94041', + costCenterId: fixture.costCenters.cafeOps.id, + geofenceRadiusMeters: 100, + }, + }); + assert.ok(createdHub.hubId); + logStep('client.hubs.create.ok', createdHub); + + const updatedHub = await apiCall(`/client/hubs/${createdHub.hubId}`, { + method: 'PUT', + token: ownerSession.sessionToken, + idempotencyKey: uniqueKey('update-hub'), + body: { + name: `${createdHub.name || 'Smoke Hub'} Updated`, + geofenceRadiusMeters: 140, + }, + }); + logStep('client.hubs.update.ok', updatedHub); + + const assignedHubManager = await apiCall(`/client/hubs/${createdHub.hubId}/managers`, { + method: 'POST', + token: ownerSession.sessionToken, + idempotencyKey: uniqueKey('assign-hub-manager'), + body: { + managerUserId: fixture.users.operationsManager.id, + }, + }); + logStep('client.hubs.assign-manager.ok', assignedHubManager); + + const assignedNfc = await apiCall(`/client/hubs/${createdHub.hubId}/assign-nfc`, { + method: 'POST', + token: ownerSession.sessionToken, + idempotencyKey: uniqueKey('assign-hub-nfc'), + body: { + nfcTagId: `NFC-SMOKE-${Date.now()}`, + }, + }); + logStep('client.hubs.assign-nfc.ok', assignedNfc); + + const deletedHub = await apiCall(`/client/hubs/${createdHub.hubId}`, { + method: 'DELETE', + token: ownerSession.sessionToken, + idempotencyKey: uniqueKey('delete-hub'), + body: { + reason: 'smoke cleanup', + }, + }); + logStep('client.hubs.delete.ok', deletedHub); + + const disputedInvoice = await apiCall(`/client/billing/invoices/${invoiceId}/dispute`, { + method: 'POST', + token: ownerSession.sessionToken, + idempotencyKey: uniqueKey('invoice-dispute'), + body: { + reason: 'Smoke dispute before approval', + }, + }); + logStep('client.billing.invoice-dispute.ok', disputedInvoice); + + const approvedInvoice = await apiCall(`/client/billing/invoices/${invoiceId}/approve`, { + method: 'POST', + token: ownerSession.sessionToken, + idempotencyKey: uniqueKey('invoice-approve'), + body: {}, + }); + logStep('client.billing.invoice-approve.ok', approvedInvoice); + + const createdOneTimeOrder = await apiCall('/client/orders/one-time', { + method: 'POST', + token: ownerSession.sessionToken, + idempotencyKey: uniqueKey('order-one-time'), + body: { + hubId: fixture.clockPoint.id, + vendorId: fixture.vendor.id, + eventName: `Smoke One-Time ${Date.now()}`, + orderDate: isoDate(3), + serviceType: 'RESTAURANT', + positions: [ + { + roleId: fixture.roles.barista.id, + startTime: '08:00', + endTime: '16:00', + workerCount: 1, + payRateCents: 2200, + billRateCents: 3500, + }, + ], + }, + }); + assert.ok(createdOneTimeOrder.orderId); + logStep('client.orders.create-one-time.ok', createdOneTimeOrder); + + const createdRecurringOrder = await apiCall('/client/orders/recurring', { + method: 'POST', + token: ownerSession.sessionToken, + idempotencyKey: uniqueKey('order-recurring'), + body: { + hubId: fixture.clockPoint.id, + vendorId: fixture.vendor.id, + eventName: `Smoke Recurring ${Date.now()}`, + startDate: isoDate(5), + endDate: isoDate(10), + recurrenceDays: [1, 3, 5], + serviceType: 'RESTAURANT', + positions: [ + { + roleId: fixture.roles.barista.id, + startTime: '09:00', + endTime: '15:00', + workersNeeded: 1, + payRateCents: 2300, + billRateCents: 3600, + }, + ], + }, + }); + assert.ok(createdRecurringOrder.orderId); + logStep('client.orders.create-recurring.ok', createdRecurringOrder); + + const createdPermanentOrder = await apiCall('/client/orders/permanent', { + method: 'POST', + token: ownerSession.sessionToken, + idempotencyKey: uniqueKey('order-permanent'), + body: { + hubId: fixture.clockPoint.id, + vendorId: fixture.vendor.id, + eventName: `Smoke Permanent ${Date.now()}`, + startDate: isoDate(7), + daysOfWeek: [1, 2, 3, 4, 5], + horizonDays: 14, + serviceType: 'RESTAURANT', + positions: [ + { + roleId: fixture.roles.barista.id, + startTime: '07:00', + endTime: '13:00', + workersNeeded: 1, + payRateCents: 2200, + billRateCents: 3500, + }, + ], + }, + }); + assert.ok(createdPermanentOrder.orderId); + logStep('client.orders.create-permanent.ok', createdPermanentOrder); + + const editedOrderCopy = await apiCall(`/client/orders/${fixture.orders.completed.id}/edit`, { + method: 'POST', + token: ownerSession.sessionToken, + idempotencyKey: uniqueKey('order-edit'), + body: { + eventName: `Edited Copy ${Date.now()}`, + }, + }); + assert.ok(editedOrderCopy.orderId); + logStep('client.orders.edit-copy.ok', editedOrderCopy); + + const cancelledOrder = await apiCall(`/client/orders/${createdOneTimeOrder.orderId}/cancel`, { + method: 'POST', + token: ownerSession.sessionToken, + idempotencyKey: uniqueKey('order-cancel'), + body: { + reason: 'Smoke cancel validation', + }, + }); + logStep('client.orders.cancel.ok', cancelledOrder); + + const coverageReview = await apiCall('/client/coverage/reviews', { + method: 'POST', + token: ownerSession.sessionToken, + idempotencyKey: uniqueKey('coverage-review'), + body: { + staffId: fixture.staff.ana.id, + assignmentId: fixture.assignments.completedAna.id, + rating: 5, + markAsFavorite: true, + issueFlags: [], + feedback: 'Smoke review', + }, + }); + logStep('client.coverage.review.ok', coverageReview); + + const staffSession = await apiCall('/staff/session', { + token: staffAuth.idToken, + }); + assert.equal(staffSession.staff.staffId, fixture.staff.ana.id); + logStep('staff.session.ok', staffSession); + + const staffDashboard = await apiCall('/staff/dashboard', { + token: staffAuth.idToken, + }); + assert.ok(Array.isArray(staffDashboard.recommendedShifts)); + logStep('staff.dashboard.ok', { + todaysShifts: staffDashboard.todaysShifts.length, + recommendedShifts: staffDashboard.recommendedShifts.length, + }); + + const staffProfileCompletion = await apiCall('/staff/profile-completion', { + token: staffAuth.idToken, + }); + logStep('staff.profile-completion.ok', staffProfileCompletion); + + const staffAvailability = await apiCall('/staff/availability', { + token: staffAuth.idToken, + }); + assert.ok(Array.isArray(staffAvailability.items)); + logStep('staff.availability.ok', { count: staffAvailability.items.length }); + + const todaysShifts = await apiCall('/staff/clock-in/shifts/today', { + token: staffAuth.idToken, + }); + assert.ok(Array.isArray(todaysShifts.items)); + logStep('staff.clock-in.shifts-today.ok', { count: todaysShifts.items.length }); + + const attendanceStatusBefore = await apiCall('/staff/clock-in/status', { + token: staffAuth.idToken, + }); + logStep('staff.clock-in.status-before.ok', attendanceStatusBefore); + + const paymentsSummary = await apiCall('/staff/payments/summary?period=month', { + token: staffAuth.idToken, + }); + logStep('staff.payments.summary.ok', paymentsSummary); + + const paymentsHistory = await apiCall('/staff/payments/history?period=month', { + token: staffAuth.idToken, + }); + assert.ok(Array.isArray(paymentsHistory.items)); + logStep('staff.payments.history.ok', { count: paymentsHistory.items.length }); + + const paymentsChart = await apiCall('/staff/payments/chart?period=month', { + token: staffAuth.idToken, + }); + assert.ok(Array.isArray(paymentsChart.items)); + logStep('staff.payments.chart.ok', { count: paymentsChart.items.length }); + + const assignedShifts = await apiCall(`/staff/shifts/assigned?${reportWindow}`, { + token: staffAuth.idToken, + }); + assert.ok(Array.isArray(assignedShifts.items)); + logStep('staff.shifts.assigned.ok', { count: assignedShifts.items.length }); + + const openShifts = await apiCall('/staff/shifts/open', { + token: staffAuth.idToken, + }); + assert.ok(openShifts.items.some((shift) => shift.shiftId === fixture.shifts.available.id)); + logStep('staff.shifts.open.ok', { count: openShifts.items.length }); + + const pendingShifts = await apiCall('/staff/shifts/pending', { + token: staffAuth.idToken, + }); + assert.ok(pendingShifts.items.some((item) => item.shiftId === fixture.shifts.assigned.id)); + logStep('staff.shifts.pending.ok', { count: pendingShifts.items.length }); + + const cancelledShifts = await apiCall('/staff/shifts/cancelled', { + token: staffAuth.idToken, + }); + assert.ok(Array.isArray(cancelledShifts.items)); + logStep('staff.shifts.cancelled.ok', { count: cancelledShifts.items.length }); + + const completedShifts = await apiCall('/staff/shifts/completed', { + token: staffAuth.idToken, + }); + assert.ok(Array.isArray(completedShifts.items)); + logStep('staff.shifts.completed.ok', { count: completedShifts.items.length }); + + const shiftDetail = await apiCall(`/staff/shifts/${fixture.shifts.available.id}`, { + token: staffAuth.idToken, + }); + assert.equal(shiftDetail.shiftId, fixture.shifts.available.id); + logStep('staff.shifts.detail.ok', shiftDetail); + + const profileSections = await apiCall('/staff/profile/sections', { + token: staffAuth.idToken, + }); + logStep('staff.profile.sections.ok', profileSections); + + const personalInfo = await apiCall('/staff/profile/personal-info', { + token: staffAuth.idToken, + }); + logStep('staff.profile.personal-info.ok', personalInfo); + + const industries = await apiCall('/staff/profile/industries', { + token: staffAuth.idToken, + }); + assert.ok(Array.isArray(industries.items)); + logStep('staff.profile.industries.ok', industries); + + const skills = await apiCall('/staff/profile/skills', { + token: staffAuth.idToken, + }); + assert.ok(Array.isArray(skills.items)); + logStep('staff.profile.skills.ok', skills); + + const profileDocumentsBefore = await apiCall('/staff/profile/documents', { + token: staffAuth.idToken, + }); + assert.ok(Array.isArray(profileDocumentsBefore.items)); + logStep('staff.profile.documents-before.ok', { count: profileDocumentsBefore.items.length }); + + const attireChecklistBefore = await apiCall('/staff/profile/attire', { + token: staffAuth.idToken, + }); + assert.ok(Array.isArray(attireChecklistBefore.items)); + logStep('staff.profile.attire-before.ok', { count: attireChecklistBefore.items.length }); + + const taxForms = await apiCall('/staff/profile/tax-forms', { + token: staffAuth.idToken, + }); + assert.ok(Array.isArray(taxForms.items)); + logStep('staff.profile.tax-forms.ok', { count: taxForms.items.length }); + + const emergencyContactsBefore = await apiCall('/staff/profile/emergency-contacts', { + token: staffAuth.idToken, + }); + assert.ok(Array.isArray(emergencyContactsBefore.items)); + logStep('staff.profile.emergency-contacts-before.ok', { count: emergencyContactsBefore.items.length }); + + const certificatesBefore = await apiCall('/staff/profile/certificates', { + token: staffAuth.idToken, + }); + assert.ok(Array.isArray(certificatesBefore.items)); + logStep('staff.profile.certificates-before.ok', { count: certificatesBefore.items.length }); + + const bankAccountsBefore = await apiCall('/staff/profile/bank-accounts', { + token: staffAuth.idToken, + }); + assert.ok(Array.isArray(bankAccountsBefore.items)); + logStep('staff.profile.bank-accounts-before.ok', { count: bankAccountsBefore.items.length }); + + const benefits = await apiCall('/staff/profile/benefits', { + token: staffAuth.idToken, + }); + assert.ok(Array.isArray(benefits.items)); + logStep('staff.profile.benefits.ok', { count: benefits.items.length }); + + const timeCard = await apiCall(`/staff/profile/time-card?month=${new Date().getUTCMonth() + 1}&year=${new Date().getUTCFullYear()}`, { + token: staffAuth.idToken, + }); + assert.ok(Array.isArray(timeCard.items)); + logStep('staff.profile.time-card.ok', { count: timeCard.items.length }); + + const privacyBefore = await apiCall('/staff/profile/privacy', { + token: staffAuth.idToken, + }); + logStep('staff.profile.privacy-before.ok', privacyBefore); + + const faqs = await apiCall('/staff/faqs'); + assert.ok(Array.isArray(faqs.items)); + logStep('staff.faqs.ok', { count: faqs.items.length }); + + const faqSearch = await apiCall('/staff/faqs/search?q=payments'); + assert.ok(Array.isArray(faqSearch.items)); + logStep('staff.faqs.search.ok', { count: faqSearch.items.length }); + + const updatedAvailability = await apiCall('/staff/availability', { + method: 'PUT', + token: staffAuth.idToken, + idempotencyKey: uniqueKey('staff-availability'), + body: { + dayOfWeek: 2, + availabilityStatus: 'PARTIAL', + slots: [{ start: '10:00', end: '18:00' }], + }, + }); + logStep('staff.availability.update.ok', updatedAvailability); + + const quickSetAvailability = await apiCall('/staff/availability/quick-set', { + method: 'POST', + token: staffAuth.idToken, + idempotencyKey: uniqueKey('staff-availability-quick-set'), + body: { + quickSetType: 'weekdays', + startDate: isoTimestamp(0), + endDate: isoTimestamp(24 * 7), + }, + }); + logStep('staff.availability.quick-set.ok', quickSetAvailability); + + const personalInfoUpdate = await apiCall('/staff/profile/personal-info', { + method: 'PUT', + token: staffAuth.idToken, + idempotencyKey: uniqueKey('staff-personal-info'), + body: { + firstName: 'Ana', + lastName: 'Barista', + bio: 'Smoke-tested staff bio', + preferredLocations: [ + { + label: 'Mountain View', + city: 'Mountain View', + latitude: fixture.clockPoint.latitude, + longitude: fixture.clockPoint.longitude, + radiusMiles: 20, + }, + ], + phone: fixture.staff.ana.phone, + email: staffEmail, + }, + }); + logStep('staff.profile.personal-info.update.ok', personalInfoUpdate); + + const experienceUpdate = await apiCall('/staff/profile/experience', { + method: 'PUT', + token: staffAuth.idToken, + idempotencyKey: uniqueKey('staff-experience'), + body: { + industries: ['CATERING', 'CAFE'], + skills: ['BARISTA', 'CUSTOMER_SERVICE'], + primaryRole: 'BARISTA', + }, + }); + logStep('staff.profile.experience.update.ok', experienceUpdate); + + const locationUpdate = await apiCall('/staff/profile/locations', { + method: 'PUT', + token: staffAuth.idToken, + idempotencyKey: uniqueKey('staff-locations'), + body: { + preferredLocations: [ + { + label: 'Mountain View', + city: 'Mountain View', + latitude: fixture.clockPoint.latitude, + longitude: fixture.clockPoint.longitude, + radiusMiles: 25, + }, + ], + maxDistanceMiles: 25, + }, + }); + logStep('staff.profile.locations.update.ok', locationUpdate); + + const createdEmergencyContact = await apiCall('/staff/profile/emergency-contacts', { + method: 'POST', + token: staffAuth.idToken, + idempotencyKey: uniqueKey('staff-emergency-create'), + body: { + fullName: 'Smoke Contact', + phone: '+15550009999', + relationshipType: 'Sibling', + isPrimary: false, + }, + }); + assert.ok(createdEmergencyContact.contactId); + logStep('staff.profile.emergency-contact.create.ok', createdEmergencyContact); + + const updatedEmergencyContact = await apiCall(`/staff/profile/emergency-contacts/${createdEmergencyContact.contactId}`, { + method: 'PUT', + token: staffAuth.idToken, + idempotencyKey: uniqueKey('staff-emergency-update'), + body: { + fullName: 'Smoke Contact Updated', + relationshipType: 'Brother', + }, + }); + logStep('staff.profile.emergency-contact.update.ok', updatedEmergencyContact); + + const savedW4Draft = await apiCall('/staff/profile/tax-forms/w4', { + method: 'PUT', + token: staffAuth.idToken, + idempotencyKey: uniqueKey('staff-tax-w4-draft'), + body: { + fields: { + filingStatus: 'single', + }, + }, + }); + logStep('staff.profile.tax-form.w4-draft.ok', savedW4Draft); + + const submittedI9 = await apiCall('/staff/profile/tax-forms/i9/submit', { + method: 'POST', + token: staffAuth.idToken, + idempotencyKey: uniqueKey('staff-tax-i9-submit'), + body: { + fields: { + section1Complete: true, + }, + }, + }); + logStep('staff.profile.tax-form.i9-submit.ok', submittedI9); + + const addedBankAccount = await apiCall('/staff/profile/bank-accounts', { + method: 'POST', + token: staffAuth.idToken, + idempotencyKey: uniqueKey('staff-bank-account'), + body: { + bankName: 'Demo Credit Union', + accountNumber: '1234567890', + routingNumber: '021000021', + accountType: 'checking', + }, + }); + logStep('staff.profile.bank-account.add.ok', addedBankAccount); + + const updatedPrivacy = await apiCall('/staff/profile/privacy', { + method: 'PUT', + token: staffAuth.idToken, + idempotencyKey: uniqueKey('staff-privacy'), + body: { + profileVisible: true, + }, + }); + logStep('staff.profile.privacy.update.ok', updatedPrivacy); + + const appliedShift = await apiCall(`/staff/shifts/${fixture.shifts.available.id}/apply`, { + method: 'POST', + token: staffAuth.idToken, + idempotencyKey: uniqueKey('staff-shift-apply'), + body: { + roleId: fixture.shiftRoles.availableBarista.id, + }, + }); + logStep('staff.shifts.apply.ok', appliedShift); + + const acceptedShift = await apiCall(`/staff/shifts/${fixture.shifts.assigned.id}/accept`, { + method: 'POST', + token: staffAuth.idToken, + idempotencyKey: uniqueKey('staff-shift-accept'), + body: {}, + }); + logStep('staff.shifts.accept.ok', acceptedShift); + + const clockIn = await apiCall('/staff/clock-in', { + method: 'POST', + token: staffAuth.idToken, + idempotencyKey: uniqueKey('staff-clock-in'), + body: { + shiftId: fixture.shifts.assigned.id, + sourceType: 'NFC', + nfcTagId: fixture.clockPoint.nfcTagUid, + deviceId: 'smoke-iphone-15-pro', + latitude: fixture.clockPoint.latitude, + longitude: fixture.clockPoint.longitude, + accuracyMeters: 8, + capturedAt: isoTimestamp(0), + }, + }); + logStep('staff.clock-in.ok', clockIn); + + const attendanceStatusAfterClockIn = await apiCall('/staff/clock-in/status', { + token: staffAuth.idToken, + }); + logStep('staff.clock-in.status-after.ok', attendanceStatusAfterClockIn); + + const clockOut = await apiCall('/staff/clock-out', { + method: 'POST', + token: staffAuth.idToken, + idempotencyKey: uniqueKey('staff-clock-out'), + body: { + shiftId: fixture.shifts.assigned.id, + sourceType: 'GEO', + deviceId: 'smoke-iphone-15-pro', + latitude: fixture.clockPoint.latitude, + longitude: fixture.clockPoint.longitude, + accuracyMeters: 10, + breakMinutes: 30, + capturedAt: isoTimestamp(1), + }, + }); + logStep('staff.clock-out.ok', clockOut); + + const requestedSwap = await apiCall(`/staff/shifts/${fixture.shifts.assigned.id}/request-swap`, { + method: 'POST', + token: staffAuth.idToken, + idempotencyKey: uniqueKey('staff-shift-swap'), + body: { + reason: 'Smoke swap request', + }, + }); + logStep('staff.shifts.request-swap.ok', requestedSwap); + + const uploadedProfilePhoto = await uploadFile('/staff/profile/photo', staffAuth.idToken, { + filename: 'profile-photo.jpg', + contentType: 'image/jpeg', + content: Buffer.from('fake-profile-photo'), + }); + 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, { + filename: 'government-id.jpg', + contentType: 'image/jpeg', + content: Buffer.from('fake-government-id'), + }); + assert.equal(uploadedGovId.documentId, fixture.documents.governmentId.id); + logStep('staff.profile.document.upload.ok', uploadedGovId); + + const uploadedAttire = await uploadFile(`/staff/profile/attire/${fixture.documents.attireBlackShirt.id}/upload`, staffAuth.idToken, { + filename: 'black-shirt.jpg', + contentType: 'image/jpeg', + content: Buffer.from('fake-black-shirt'), + }); + assert.equal(uploadedAttire.documentId, fixture.documents.attireBlackShirt.id); + logStep('staff.profile.attire.upload.ok', uploadedAttire); + + const certificateType = `ALCOHOL_SERVICE_${Date.now()}`; + const uploadedCertificate = await uploadFile('/staff/profile/certificates', staffAuth.idToken, { + filename: 'certificate.pdf', + contentType: 'application/pdf', + content: Buffer.from('fake-certificate'), + fields: { + certificateType, + name: 'Alcohol Service Permit', + issuer: 'Demo Issuer', + }, + }); + assert.equal(uploadedCertificate.certificateType, certificateType); + logStep('staff.profile.certificate.upload.ok', uploadedCertificate); + + const profileDocumentsAfter = await apiCall('/staff/profile/documents', { + token: staffAuth.idToken, + }); + assert.ok(profileDocumentsAfter.items.some((item) => item.documentId === fixture.documents.governmentId.id)); + logStep('staff.profile.documents-after.ok', { count: profileDocumentsAfter.items.length }); + + const certificatesAfter = await apiCall('/staff/profile/certificates', { + token: staffAuth.idToken, + }); + assert.ok(certificatesAfter.items.some((item) => item.certificateType === certificateType)); + logStep('staff.profile.certificates-after.ok', { count: certificatesAfter.items.length }); + + const deletedCertificate = await apiCall(`/staff/profile/certificates/${certificateType}`, { + method: 'DELETE', + token: staffAuth.idToken, + expectedStatus: 200, + }); + logStep('staff.profile.certificate.delete.ok', deletedCertificate); + + const clientSignOut = await apiCall('/auth/client/sign-out', { + method: 'POST', + token: ownerSession.sessionToken, + }); + logStep('auth.client.sign-out.ok', clientSignOut); + + const staffSignOut = await apiCall('/auth/staff/sign-out', { + method: 'POST', + token: staffAuth.idToken, + }); + logStep('auth.staff.sign-out.ok', staffSignOut); + + // eslint-disable-next-line no-console + console.log('LIVE_SMOKE_V2_UNIFIED_OK'); +} + +main().catch((error) => { + // eslint-disable-next-line no-console + console.error(error); + process.exit(1); +}); diff --git a/backend/unified-api/src/routes/auth.js b/backend/unified-api/src/routes/auth.js index faafa952..7d101511 100644 --- a/backend/unified-api/src/routes/auth.js +++ b/backend/unified-api/src/routes/auth.js @@ -1,14 +1,29 @@ import express from 'express'; import { AppError } from '../lib/errors.js'; -import { parseClientSignIn, parseClientSignUp, signInClient, signOutActor, signUpClient, getSessionForActor } from '../services/auth-service.js'; +import { + getSessionForActor, + parseClientSignIn, + parseClientSignUp, + parseStaffPhoneStart, + parseStaffPhoneVerify, + signInClient, + signOutActor, + signUpClient, + startStaffPhoneAuth, + verifyStaffPhoneAuth, +} from '../services/auth-service.js'; import { verifyFirebaseToken } from '../services/firebase-auth.js'; const defaultAuthService = { parseClientSignIn, parseClientSignUp, + parseStaffPhoneStart, + parseStaffPhoneVerify, signInClient, signOutActor, signUpClient, + startStaffPhoneAuth, + verifyStaffPhoneAuth, getSessionForActor, }; @@ -31,7 +46,7 @@ async function requireAuth(req, _res, next) { return next(); } - const decoded = await verifyFirebaseToken(token, { checkRevoked: true }); + const decoded = await verifyFirebaseToken(token); req.actor = { uid: decoded.uid, email: decoded.email || null, @@ -77,6 +92,32 @@ export function createAuthRouter(options = {}) { } }); + router.post('/staff/phone/start', async (req, res, next) => { + try { + const payload = authService.parseStaffPhoneStart(req.body); + const result = await authService.startStaffPhoneAuth(payload, { fetchImpl }); + return res.status(200).json({ + ...result, + requestId: req.requestId, + }); + } catch (error) { + return next(error); + } + }); + + router.post('/staff/phone/verify', async (req, res, next) => { + try { + const payload = authService.parseStaffPhoneVerify(req.body); + const session = await authService.verifyStaffPhoneAuth(payload, { fetchImpl }); + return res.status(200).json({ + ...session, + requestId: req.requestId, + }); + } catch (error) { + return next(error); + } + }); + router.get('/session', requireAuth, async (req, res, next) => { try { const session = await authService.getSessionForActor(req.actor); diff --git a/backend/unified-api/src/routes/proxy.js b/backend/unified-api/src/routes/proxy.js index 69cae510..3dcc971a 100644 --- a/backend/unified-api/src/routes/proxy.js +++ b/backend/unified-api/src/routes/proxy.js @@ -14,10 +14,91 @@ const HOP_BY_HOP_HEADERS = new Set([ 'upgrade', ]); -function resolveTargetBase(pathname) { - if (pathname.startsWith('/core')) return process.env.CORE_API_BASE_URL; - if (pathname.startsWith('/commands')) return process.env.COMMAND_API_BASE_URL; - if (pathname.startsWith('/query')) return process.env.QUERY_API_BASE_URL; +const DIRECT_CORE_ALIASES = [ + { methods: new Set(['POST']), pattern: /^\/upload-file$/, targetPath: (pathname) => `/core${pathname}` }, + { methods: new Set(['POST']), pattern: /^\/create-signed-url$/, 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\/parse$/, targetPath: (pathname) => `/core${pathname}` }, + { methods: new Set(['POST']), pattern: /^\/staff\/profile\/photo$/, targetPath: (pathname) => `/core${pathname}` }, + { + methods: new Set(['POST']), + pattern: /^\/staff\/profile\/documents\/([^/]+)\/upload$/, + targetPath: (_pathname, match) => `/core/staff/documents/${match[1]}/upload`, + }, + { + methods: new Set(['POST']), + pattern: /^\/staff\/profile\/attire\/([^/]+)\/upload$/, + targetPath: (_pathname, match) => `/core/staff/attire/${match[1]}/upload`, + }, + { + methods: new Set(['POST']), + pattern: /^\/staff\/profile\/certificates$/, + targetPath: () => '/core/staff/certificates/upload', + }, + { + methods: new Set(['DELETE']), + pattern: /^\/staff\/profile\/certificates\/([^/]+)$/, + targetPath: (_pathname, match) => `/core/staff/certificates/${match[1]}`, + }, + { methods: new Set(['POST']), pattern: /^\/staff\/documents\/([^/]+)\/upload$/, targetPath: (pathname) => `/core${pathname}` }, + { methods: new Set(['POST']), pattern: /^\/staff\/attire\/([^/]+)\/upload$/, targetPath: (pathname) => `/core${pathname}` }, + { methods: new Set(['POST']), pattern: /^\/staff\/certificates\/upload$/, targetPath: (pathname) => `/core${pathname}` }, + { methods: new Set(['DELETE']), pattern: /^\/staff\/certificates\/([^/]+)$/, targetPath: (pathname) => `/core${pathname}` }, + { methods: new Set(['POST']), pattern: /^\/verifications$/, targetPath: (pathname) => `/core${pathname}` }, + { methods: new Set(['GET']), pattern: /^\/verifications\/([^/]+)$/, targetPath: (pathname) => `/core${pathname}` }, + { methods: new Set(['POST']), pattern: /^\/verifications\/([^/]+)\/review$/, targetPath: (pathname) => `/core${pathname}` }, + { methods: new Set(['POST']), pattern: /^\/verifications\/([^/]+)\/retry$/, targetPath: (pathname) => `/core${pathname}` }, +]; + +function resolveTarget(pathname, method) { + const upperMethod = method.toUpperCase(); + + if (pathname.startsWith('/core')) { + return { + baseUrl: process.env.CORE_API_BASE_URL, + upstreamPath: pathname, + }; + } + + if (pathname.startsWith('/commands')) { + return { + baseUrl: process.env.COMMAND_API_BASE_URL, + upstreamPath: pathname, + }; + } + + if (pathname.startsWith('/query')) { + return { + baseUrl: process.env.QUERY_API_BASE_URL, + upstreamPath: pathname, + }; + } + + for (const alias of DIRECT_CORE_ALIASES) { + if (!alias.methods.has(upperMethod)) continue; + const match = pathname.match(alias.pattern); + if (!match) continue; + return { + baseUrl: process.env.CORE_API_BASE_URL, + upstreamPath: alias.targetPath(pathname, match), + }; + } + + if ((upperMethod === 'GET' || upperMethod === 'HEAD') && (pathname.startsWith('/client') || pathname.startsWith('/staff'))) { + return { + baseUrl: process.env.QUERY_API_BASE_URL, + upstreamPath: `/query${pathname}`, + }; + } + + if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(upperMethod) && (pathname.startsWith('/client') || pathname.startsWith('/staff'))) { + return { + baseUrl: process.env.COMMAND_API_BASE_URL, + upstreamPath: `/commands${pathname}`, + }; + } + return null; } @@ -30,13 +111,13 @@ function copyHeaders(source, target) { async function forwardRequest(req, res, next, fetchImpl) { try { - const requestPath = new URL(req.originalUrl, 'http://localhost').pathname; - const baseUrl = resolveTargetBase(requestPath); - if (!baseUrl) { - throw new AppError('NOT_FOUND', `No upstream configured for ${requestPath}`, 404); + const requestUrl = new URL(req.originalUrl, 'http://localhost'); + const target = resolveTarget(requestUrl.pathname, req.method); + if (!target?.baseUrl) { + throw new AppError('NOT_FOUND', `No upstream configured for ${requestUrl.pathname}`, 404); } - const url = new URL(req.originalUrl, baseUrl); + const url = new URL(`${target.upstreamPath}${requestUrl.search}`, target.baseUrl); const headers = new Headers(); for (const [key, value] of Object.entries(req.headers)) { if (value == null || HOP_BY_HOP_HEADERS.has(key.toLowerCase())) continue; @@ -69,7 +150,7 @@ export function createProxyRouter(options = {}) { const router = Router(); const fetchImpl = options.fetchImpl || fetch; - router.use(['/core', '/commands', '/query'], (req, res, next) => forwardRequest(req, res, next, fetchImpl)); + router.use((req, res, next) => forwardRequest(req, res, next, fetchImpl)); return router; } diff --git a/backend/unified-api/src/services/auth-service.js b/backend/unified-api/src/services/auth-service.js index 5f44dee0..69efe58b 100644 --- a/backend/unified-api/src/services/auth-service.js +++ b/backend/unified-api/src/services/auth-service.js @@ -2,7 +2,13 @@ import { z } from 'zod'; import { AppError } from '../lib/errors.js'; import { withTransaction } from './db.js'; import { verifyFirebaseToken, revokeUserSessions } from './firebase-auth.js'; -import { deleteAccount, signInWithPassword, signUpWithPassword } from './identity-toolkit.js'; +import { + deleteAccount, + sendVerificationCode, + signInWithPassword, + signInWithPhoneNumber, + signUpWithPassword, +} from './identity-toolkit.js'; import { loadActorContext } from './user-context.js'; const clientSignInSchema = z.object({ @@ -17,6 +23,30 @@ const clientSignUpSchema = z.object({ displayName: z.string().min(2).max(120).optional(), }); +const staffPhoneStartSchema = z.object({ + phoneNumber: z.string().min(6).max(40), + recaptchaToken: z.string().min(1).optional(), + iosReceipt: z.string().min(1).optional(), + iosSecret: z.string().min(1).optional(), + captchaResponse: z.string().min(1).optional(), + playIntegrityToken: z.string().min(1).optional(), + safetyNetToken: z.string().min(1).optional(), +}); + +const staffPhoneVerifySchema = z.object({ + mode: z.enum(['sign-in', 'sign-up']).optional(), + idToken: z.string().min(1).optional(), + sessionInfo: z.string().min(1).optional(), + code: z.string().min(1).optional(), +}).superRefine((value, ctx) => { + if (value.idToken) return; + if (value.sessionInfo && value.code) return; + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Provide idToken or sessionInfo and code', + }); +}); + function slugify(input) { return input .toLowerCase() @@ -40,9 +70,43 @@ function buildAuthEnvelope(authPayload, context) { business: context.business, vendor: context.vendor, staff: context.staff, + requiresProfileSetup: !context.staff, }; } +async function upsertUserFromDecodedToken(decoded, fallbackProfile = {}) { + await withTransaction(async (client) => { + await client.query( + ` + INSERT INTO users (id, email, display_name, phone, status, metadata) + VALUES ($1, $2, $3, $4, 'ACTIVE', COALESCE($5::jsonb, '{}'::jsonb)) + ON CONFLICT (id) DO UPDATE + SET email = COALESCE(EXCLUDED.email, users.email), + display_name = COALESCE(EXCLUDED.display_name, users.display_name), + phone = COALESCE(EXCLUDED.phone, users.phone), + metadata = COALESCE(users.metadata, '{}'::jsonb) || COALESCE(EXCLUDED.metadata, '{}'::jsonb), + updated_at = NOW() + `, + [ + decoded.uid, + decoded.email || fallbackProfile.email || null, + decoded.name || fallbackProfile.displayName || fallbackProfile.email || decoded.phone_number || null, + decoded.phone_number || fallbackProfile.phoneNumber || null, + JSON.stringify({ + provider: decoded.firebase?.sign_in_provider || fallbackProfile.provider || null, + }), + ] + ); + }); +} + +async function hydrateAuthContext(authPayload, fallbackProfile = {}) { + const decoded = await verifyFirebaseToken(authPayload.idToken); + await upsertUserFromDecodedToken(decoded, fallbackProfile); + const context = await loadActorContext(decoded.uid); + return buildAuthEnvelope(authPayload, context); +} + export function parseClientSignIn(body) { const parsed = clientSignInSchema.safeParse(body || {}); if (!parsed.success) { @@ -63,6 +127,26 @@ export function parseClientSignUp(body) { return parsed.data; } +export function parseStaffPhoneStart(body) { + const parsed = staffPhoneStartSchema.safeParse(body || {}); + if (!parsed.success) { + throw new AppError('VALIDATION_ERROR', 'Invalid staff phone start payload', 400, { + issues: parsed.error.issues, + }); + } + return parsed.data; +} + +export function parseStaffPhoneVerify(body) { + const parsed = staffPhoneVerifySchema.safeParse(body || {}); + if (!parsed.success) { + throw new AppError('VALIDATION_ERROR', 'Invalid staff phone verify payload', 400, { + issues: parsed.error.issues, + }); + } + return parsed.data; +} + export async function getSessionForActor(actor) { return loadActorContext(actor.uid); } @@ -70,6 +154,7 @@ export async function getSessionForActor(actor) { export async function signInClient(payload, { fetchImpl = fetch } = {}) { const authPayload = await signInWithPassword(payload, fetchImpl); const decoded = await verifyFirebaseToken(authPayload.idToken); + await upsertUserFromDecodedToken(decoded, payload); const context = await loadActorContext(decoded.uid); if (!context.user || !context.business) { @@ -151,6 +236,68 @@ export async function signUpClient(payload, { fetchImpl = fetch } = {}) { } } +function shouldUseClientSdkStaffFlow(payload) { + return !payload.recaptchaToken && !payload.iosReceipt && !payload.captchaResponse && !payload.playIntegrityToken && !payload.safetyNetToken; +} + +export async function startStaffPhoneAuth(payload, { fetchImpl = fetch } = {}) { + if (shouldUseClientSdkStaffFlow(payload)) { + return { + mode: 'CLIENT_FIREBASE_SDK', + provider: 'firebase-phone-auth', + phoneNumber: payload.phoneNumber, + nextStep: 'Complete phone verification in the mobile client, then call /auth/staff/phone/verify with the Firebase idToken.', + }; + } + + const authPayload = await sendVerificationCode( + { + phoneNumber: payload.phoneNumber, + recaptchaToken: payload.recaptchaToken, + iosReceipt: payload.iosReceipt, + iosSecret: payload.iosSecret, + captchaResponse: payload.captchaResponse, + playIntegrityToken: payload.playIntegrityToken, + safetyNetToken: payload.safetyNetToken, + }, + fetchImpl + ); + + return { + mode: 'IDENTITY_TOOLKIT_SMS', + phoneNumber: payload.phoneNumber, + sessionInfo: authPayload.sessionInfo, + }; +} + +export async function verifyStaffPhoneAuth(payload, { fetchImpl = fetch } = {}) { + if (payload.idToken) { + return hydrateAuthContext( + { + idToken: payload.idToken, + refreshToken: null, + expiresIn: 3600, + }, + { + provider: 'firebase-phone-auth', + } + ); + } + + const authPayload = await signInWithPhoneNumber( + { + sessionInfo: payload.sessionInfo, + code: payload.code, + operation: payload.mode === 'sign-up' ? 'SIGN_UP_OR_IN' : undefined, + }, + fetchImpl + ); + + return hydrateAuthContext(authPayload, { + provider: 'firebase-phone-auth', + }); +} + export async function signOutActor(actor) { await revokeUserSessions(actor.uid); return { signedOut: true }; diff --git a/backend/unified-api/src/services/firebase-auth.js b/backend/unified-api/src/services/firebase-auth.js index e441b13f..ed2c1839 100644 --- a/backend/unified-api/src/services/firebase-auth.js +++ b/backend/unified-api/src/services/firebase-auth.js @@ -16,3 +16,8 @@ export async function revokeUserSessions(uid) { ensureAdminApp(); await getAuth().revokeRefreshTokens(uid); } + +export async function createCustomToken(uid) { + ensureAdminApp(); + return getAuth().createCustomToken(uid); +} diff --git a/backend/unified-api/src/services/identity-toolkit.js b/backend/unified-api/src/services/identity-toolkit.js index 0b477e4b..f9ed245f 100644 --- a/backend/unified-api/src/services/identity-toolkit.js +++ b/backend/unified-api/src/services/identity-toolkit.js @@ -56,6 +56,33 @@ export async function signUpWithPassword({ email, password }, fetchImpl = fetch) ); } +export async function sendVerificationCode(payload, fetchImpl = fetch) { + return callIdentityToolkit( + 'accounts:sendVerificationCode', + payload, + fetchImpl + ); +} + +export async function signInWithPhoneNumber(payload, fetchImpl = fetch) { + return callIdentityToolkit( + 'accounts:signInWithPhoneNumber', + payload, + fetchImpl + ); +} + +export async function signInWithCustomToken(payload, fetchImpl = fetch) { + return callIdentityToolkit( + 'accounts:signInWithCustomToken', + { + token: payload.token, + returnSecureToken: true, + }, + fetchImpl + ); +} + export async function deleteAccount({ idToken }, fetchImpl = fetch) { return callIdentityToolkit( 'accounts:delete', diff --git a/backend/unified-api/test/app.test.js b/backend/unified-api/test/app.test.js index 144594ef..113cfbec 100644 --- a/backend/unified-api/test/app.test.js +++ b/backend/unified-api/test/app.test.js @@ -110,3 +110,75 @@ test('proxy forwards query routes to query base url', async () => { assert.equal(res.status, 200); assert.equal(seenUrl, 'https://query.example/query/test-route?foo=bar'); }); + +test('proxy forwards direct client read routes to query 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).get('/client/dashboard'); + + assert.equal(res.status, 200); + assert.equal(seenUrl, 'https://query.example/query/client/dashboard'); +}); + +test('proxy forwards direct client write routes to command 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('/client/orders/one-time') + .set('Authorization', 'Bearer test-token') + .send({ ok: true }); + + assert.equal(res.status, 200); + assert.equal(seenUrl, 'https://command.example/commands/client/orders/one-time'); +}); + +test('proxy forwards direct core 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; + 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('/staff/profile/certificates') + .set('Authorization', 'Bearer test-token') + .send({ ok: true }); + + assert.equal(res.status, 200); + assert.equal(seenUrl, 'https://core.example/core/staff/certificates/upload'); +}); diff --git a/backend/unified-api/test/staff-auth.test.js b/backend/unified-api/test/staff-auth.test.js new file mode 100644 index 00000000..37c8aa42 --- /dev/null +++ b/backend/unified-api/test/staff-auth.test.js @@ -0,0 +1,61 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import request from 'supertest'; +import { createApp } from '../src/app.js'; + +process.env.AUTH_BYPASS = 'true'; + +function createAuthService() { + return { + parseClientSignIn: (body) => body, + parseClientSignUp: (body) => body, + parseStaffPhoneStart: (body) => body, + parseStaffPhoneVerify: (body) => body, + signInClient: async () => assert.fail('signInClient should not be called'), + signUpClient: async () => assert.fail('signUpClient should not be called'), + signOutActor: async () => ({ signedOut: true }), + getSessionForActor: async () => ({ user: { userId: 'u1' } }), + startStaffPhoneAuth: async (payload) => ({ + mode: 'CLIENT_FIREBASE_SDK', + phoneNumber: payload.phoneNumber, + nextStep: 'continue in app', + }), + verifyStaffPhoneAuth: async (payload) => ({ + sessionToken: payload.idToken || 'token', + refreshToken: 'refresh', + expiresInSeconds: 3600, + user: { id: 'staff-user' }, + tenant: { tenantId: 'tenant-1' }, + vendor: { vendorId: 'vendor-1' }, + staff: { staffId: 'staff-1' }, + requiresProfileSetup: false, + }), + }; +} + +test('POST /auth/staff/phone/start returns injected start payload', async () => { + const app = createApp({ authService: createAuthService() }); + const res = await request(app) + .post('/auth/staff/phone/start') + .send({ + phoneNumber: '+15555550123', + }); + + assert.equal(res.status, 200); + assert.equal(res.body.mode, 'CLIENT_FIREBASE_SDK'); + assert.equal(res.body.phoneNumber, '+15555550123'); +}); + +test('POST /auth/staff/phone/verify returns injected auth envelope', async () => { + const app = createApp({ authService: createAuthService() }); + const res = await request(app) + .post('/auth/staff/phone/verify') + .send({ + idToken: 'firebase-id-token', + }); + + assert.equal(res.status, 200); + assert.equal(res.body.sessionToken, 'firebase-id-token'); + assert.equal(res.body.staff.staffId, 'staff-1'); + assert.equal(res.body.requiresProfileSetup, false); +}); diff --git a/docs/BACKEND/API_GUIDES/V2/README.md b/docs/BACKEND/API_GUIDES/V2/README.md index 1a452753..2bf94573 100644 --- a/docs/BACKEND/API_GUIDES/V2/README.md +++ b/docs/BACKEND/API_GUIDES/V2/README.md @@ -2,33 +2,48 @@ This is the frontend-facing source of truth for the v2 backend. -## 1) Frontend entrypoint +## 1) Use one base URL -Frontend should target one public base URL: +Frontend should call one public gateway: ```env -API_V2_BASE_URL= +API_V2_BASE_URL=https://krow-api-v2-933560802882.us-central1.run.app ``` -The unified v2 gateway exposes: +Frontend should not call the internal `core`, `command`, or `query` Cloud Run services directly. -- `/auth/*` -- `/core/*` -- `/commands/*` -- `/query/*` -- `/query/client/*` -- `/query/staff/*` +## 2) Current status -Internal services still stay separate behind that gateway. +The unified v2 gateway is ready for frontend integration in `dev`. -## 2) Internal service split +What was validated live against the deployed stack: -| Use case | Internal service | -| --- | --- | -| File upload, signed URLs, model calls, verification helpers | `core-api-v2` | -| Business writes and workflow actions | `command-api-v2` | -| Screen reads and mobile read models | `query-api-v2` | -| Frontend-facing single host and auth wrappers | `krow-api-v2` | +- client sign-in +- staff auth bootstrap +- client dashboard, billing, coverage, hubs, vendors, managers, team members, orders, and reports +- client hub and order write flows +- client invoice approve and dispute +- staff dashboard, availability, payments, shifts, profile sections, documents, certificates, attire, bank accounts, benefits, and time card +- staff availability, profile, tax form, bank account, shift apply, shift accept, clock-in, clock-out, and swap request +- direct file upload helpers and verification job creation through the unified host +- client and staff sign-out + +The live validation command is: + +```bash +source ~/.nvm/nvm.sh +nvm use 23.5.0 +node backend/unified-api/scripts/live-smoke-v2-unified.mjs +``` + +The demo tenant can be reset with: + +```bash +source ~/.nvm/nvm.sh +nvm use 23.5.0 +cd backend/command-api +npm run seed:v2-demo +``` ## 3) Auth and headers @@ -38,13 +53,19 @@ Protected routes require: Authorization: Bearer ``` -Command routes also require: +Write routes also require: ```http Idempotency-Key: ``` -All services return the same error envelope: +For now: + +- backend wraps sign-in and sign-out +- frontend can keep using Firebase token refresh on the client +- backend is the only thing frontend should call for session-oriented API flows + +All routes return the same error envelope: ```json { @@ -55,83 +76,35 @@ All services return the same error envelope: } ``` -## 4) What frontend can use now on this branch +## 4) Route model -### Unified gateway +Frontend sees one base URL and one route shape: -- `POST /auth/client/sign-in` -- `POST /auth/client/sign-up` -- `POST /auth/sign-out` -- `POST /auth/client/sign-out` -- `POST /auth/staff/sign-out` -- `GET /auth/session` -- Proxy access to `/core/*`, `/commands/*`, `/query/*` +- `/auth/*` +- `/client/*` +- `/staff/*` +- direct upload aliases like `/upload-file` and `/staff/profile/*` -### Client read routes +Internally, the gateway still forwards to: -- `GET /query/client/session` -- `GET /query/client/dashboard` -- `GET /query/client/reorders` -- `GET /query/client/billing/accounts` -- `GET /query/client/billing/invoices/pending` -- `GET /query/client/billing/invoices/history` -- `GET /query/client/billing/current-bill` -- `GET /query/client/billing/savings` -- `GET /query/client/billing/spend-breakdown` -- `GET /query/client/coverage` -- `GET /query/client/coverage/stats` -- `GET /query/client/hubs` -- `GET /query/client/cost-centers` -- `GET /query/client/vendors` -- `GET /query/client/vendors/:vendorId/roles` -- `GET /query/client/hubs/:hubId/managers` -- `GET /query/client/orders/view` +| Frontend use case | Internal service | +| --- | --- | +| auth/session wrapper | `krow-api-v2` | +| uploads, signed URLs, model calls, verification workflows | `core-api-v2` | +| writes and workflow actions | `command-api-v2` | +| reads and mobile read models | `query-api-v2` | -### Staff read routes +## 5) Frontend integration rule -- `GET /query/staff/session` -- `GET /query/staff/dashboard` -- `GET /query/staff/profile-completion` -- `GET /query/staff/availability` -- `GET /query/staff/clock-in/shifts/today` -- `GET /query/staff/clock-in/status` -- `GET /query/staff/payments/summary` -- `GET /query/staff/payments/history` -- `GET /query/staff/payments/chart` -- `GET /query/staff/shifts/assigned` -- `GET /query/staff/shifts/open` -- `GET /query/staff/shifts/pending` -- `GET /query/staff/shifts/cancelled` -- `GET /query/staff/shifts/completed` -- `GET /query/staff/shifts/:shiftId` -- `GET /query/staff/profile/sections` -- `GET /query/staff/profile/personal-info` -- `GET /query/staff/profile/industries` -- `GET /query/staff/profile/skills` -- `GET /query/staff/profile/documents` -- `GET /query/staff/profile/certificates` -- `GET /query/staff/profile/bank-accounts` -- `GET /query/staff/profile/benefits` +Use the unified routes first. -### Existing v2 routes still valid +Do not build new frontend work on: -- `/core/*` routes documented in `core-api.md` -- `/commands/*` routes documented in `command-api.md` -- `/query/tenants/*` routes documented in `query-api.md` +- `/query/tenants/*` +- `/commands/*` +- `/core/*` -## 5) Remaining gaps after this slice - -Still not implemented yet: - -- staff phone OTP wrapper endpoints -- hub write flows -- hub NFC assignment write route -- invoice approve and dispute commands -- staff apply / decline / request swap commands -- staff profile update commands -- availability write commands -- reports suite -- durable verification persistence in `core-api-v2` +Those routes still exist for backend/internal compatibility, but mobile/frontend migration should target the unified surface documented in [Unified API](./unified-api.md). ## 6) Docs @@ -139,4 +112,4 @@ Still not implemented yet: - [Core API](./core-api.md) - [Command API](./command-api.md) - [Query API](./query-api.md) -- [Mobile gap analysis](./mobile-api-gap-analysis.md) +- [Mobile API Reconciliation](./mobile-api-gap-analysis.md) diff --git a/docs/BACKEND/API_GUIDES/V2/mobile-api-gap-analysis.md b/docs/BACKEND/API_GUIDES/V2/mobile-api-gap-analysis.md index 97e5e7e9..9e559ffd 100644 --- a/docs/BACKEND/API_GUIDES/V2/mobile-api-gap-analysis.md +++ b/docs/BACKEND/API_GUIDES/V2/mobile-api-gap-analysis.md @@ -1,66 +1,45 @@ -# Mobile API Gap Analysis +# Mobile API Reconciliation Source compared against implementation: -- `/Users/wiel/Downloads/mobile-backend-api-specification.md` +- `mobile-backend-api-specification.md` -## Implemented in this slice +## Result -- unified frontend-facing base URL design -- client auth wrapper for email/password sign-in and sign-up -- auth session and sign-out endpoints -- client read surface for dashboard, billing, coverage, hubs, vendor lookup, and date-range order items -- staff read surface for dashboard, availability, clock-in reads, payments, shifts, and profile sections -- schema support for: - - cost centers - - hub managers - - recurring staff availability - - staff benefits -- seed support for: - - authenticated demo staff user - - cost center and hub manager data - - staff benefits and availability - - attire and tax-form example documents +The current mobile v2 surface is implemented behind the unified gateway and validated live in `dev`. -## Still missing +That includes: -### Auth +- auth session routes +- client dashboard, billing, coverage, hubs, vendor lookup, managers, team members, orders, and reports +- client order, hub, coverage review, and invoice write flows +- staff dashboard, availability, payments, shifts, profile sections, documents, attire, certificates, bank accounts, benefits, privacy, and frequently asked questions +- staff availability, tax forms, emergency contacts, bank account, shift decision, clock-in/out, and swap write flows +- upload and verification flows for profile photo, government document, attire, and certificates -- staff phone OTP start -- staff OTP verify -- staff profile setup endpoint +## What was validated live -### Client writes +The live smoke executed successfully against: -- hub create -- hub update -- hub delete -- hub NFC assignment -- assign manager to hub -- invoice approve -- invoice dispute +- `https://krow-api-v2-933560802882.us-central1.run.app` +- Firebase demo users +- `krow-sql-v2` +- `krow-core-api-v2` +- `krow-command-api-v2` +- `krow-query-api-v2` -### Staff writes +The validation script is: -- availability update -- availability quick set -- shift apply -- shift decline -- request swap -- personal info update -- preferred locations update -- profile photo upload wrapper +```bash +node backend/unified-api/scripts/live-smoke-v2-unified.mjs +``` -### Reports +## Remaining work -- report summary -- daily ops -- spend -- coverage -- forecast -- performance -- no-show +The remaining items are not blockers for current mobile frontend migration. -### Core persistence +They are follow-up items: -- `core-api-v2` verification jobs still need durable SQL persistence +- extend the same unified pattern to new screens added after the current mobile specification +- add stronger observability and contract automation around the unified route surface +- keep refining reporting and financial read models as product scope expands diff --git a/docs/BACKEND/API_GUIDES/V2/unified-api.md b/docs/BACKEND/API_GUIDES/V2/unified-api.md index 9b20f022..f80ef542 100644 --- a/docs/BACKEND/API_GUIDES/V2/unified-api.md +++ b/docs/BACKEND/API_GUIDES/V2/unified-api.md @@ -1,50 +1,168 @@ # Unified API V2 -This service exists so frontend can use one base URL without forcing backend into one codebase. +Frontend should use this service as the single base URL: -## Base idea +- `https://krow-api-v2-933560802882.us-central1.run.app` -Frontend talks to one service: +The gateway keeps backend services separate internally, but frontend should treat it as one API. -- `krow-api-v2` +## 1) Auth routes -That gateway does two things: - -1. exposes auth/session endpoints -2. forwards requests to the right internal v2 service - -## Route groups - -### Auth +### Client auth - `POST /auth/client/sign-in` - `POST /auth/client/sign-up` -- `POST /auth/sign-out` - `POST /auth/client/sign-out` + +### Staff auth + +- `POST /auth/staff/phone/start` +- `POST /auth/staff/phone/verify` - `POST /auth/staff/sign-out` + +### Shared auth + - `GET /auth/session` +- `POST /auth/sign-out` -### Proxy passthrough +## 2) Client routes -- `/core/*` -> `core-api-v2` -- `/commands/*` -> `command-api-v2` -- `/query/*` -> `query-api-v2` +### Client reads -### Mobile read models +- `GET /client/session` +- `GET /client/dashboard` +- `GET /client/reorders` +- `GET /client/billing/accounts` +- `GET /client/billing/invoices/pending` +- `GET /client/billing/invoices/history` +- `GET /client/billing/current-bill` +- `GET /client/billing/savings` +- `GET /client/billing/spend-breakdown` +- `GET /client/coverage` +- `GET /client/coverage/stats` +- `GET /client/coverage/core-team` +- `GET /client/hubs` +- `GET /client/cost-centers` +- `GET /client/vendors` +- `GET /client/vendors/:vendorId/roles` +- `GET /client/hubs/:hubId/managers` +- `GET /client/team-members` +- `GET /client/orders/view` +- `GET /client/orders/:orderId/reorder-preview` +- `GET /client/reports/summary` +- `GET /client/reports/daily-ops` +- `GET /client/reports/spend` +- `GET /client/reports/coverage` +- `GET /client/reports/forecast` +- `GET /client/reports/performance` +- `GET /client/reports/no-show` -These are served by `query-api-v2` but frontend should still call them through the unified host: +### Client writes -- `/query/client/*` -- `/query/staff/*` +- `POST /client/orders/one-time` +- `POST /client/orders/recurring` +- `POST /client/orders/permanent` +- `POST /client/orders/:orderId/edit` +- `POST /client/orders/:orderId/cancel` +- `POST /client/hubs` +- `PUT /client/hubs/:hubId` +- `DELETE /client/hubs/:hubId` +- `POST /client/hubs/:hubId/assign-nfc` +- `POST /client/hubs/:hubId/managers` +- `POST /client/billing/invoices/:invoiceId/approve` +- `POST /client/billing/invoices/:invoiceId/dispute` +- `POST /client/coverage/reviews` +- `POST /client/coverage/late-workers/:assignmentId/cancel` -## Why this shape +## 3) Staff routes -- frontend gets one base URL -- backend keeps separate read, write, and service helpers -- we can scale or refactor internals later without breaking frontend paths +### Staff reads -## Current auth note +- `GET /staff/session` +- `GET /staff/dashboard` +- `GET /staff/profile-completion` +- `GET /staff/availability` +- `GET /staff/clock-in/shifts/today` +- `GET /staff/clock-in/status` +- `GET /staff/payments/summary` +- `GET /staff/payments/history` +- `GET /staff/payments/chart` +- `GET /staff/shifts/assigned` +- `GET /staff/shifts/open` +- `GET /staff/shifts/pending` +- `GET /staff/shifts/cancelled` +- `GET /staff/shifts/completed` +- `GET /staff/shifts/:shiftId` +- `GET /staff/profile/sections` +- `GET /staff/profile/personal-info` +- `GET /staff/profile/industries` +- `GET /staff/profile/skills` +- `GET /staff/profile/documents` +- `GET /staff/profile/attire` +- `GET /staff/profile/tax-forms` +- `GET /staff/profile/emergency-contacts` +- `GET /staff/profile/certificates` +- `GET /staff/profile/bank-accounts` +- `GET /staff/profile/benefits` +- `GET /staff/profile/time-card` +- `GET /staff/profile/privacy` +- `GET /staff/faqs` +- `GET /staff/faqs/search` -Client email/password auth is wrapped here. +### Staff writes -Staff phone OTP is not wrapped here yet. That still needs its own proper provider-backed implementation rather than a fake backend OTP flow. +- `POST /staff/profile/setup` +- `POST /staff/clock-in` +- `POST /staff/clock-out` +- `PUT /staff/availability` +- `POST /staff/availability/quick-set` +- `POST /staff/shifts/:shiftId/apply` +- `POST /staff/shifts/:shiftId/accept` +- `POST /staff/shifts/:shiftId/decline` +- `POST /staff/shifts/:shiftId/request-swap` +- `PUT /staff/profile/personal-info` +- `PUT /staff/profile/experience` +- `PUT /staff/profile/locations` +- `POST /staff/profile/emergency-contacts` +- `PUT /staff/profile/emergency-contacts/:contactId` +- `PUT /staff/profile/tax-forms/:formType` +- `POST /staff/profile/tax-forms/:formType/submit` +- `POST /staff/profile/bank-accounts` +- `PUT /staff/profile/privacy` + +## 4) Upload and verification routes + +These are exposed as direct unified aliases even though they are backed by `core-api-v2`. + +### Generic core aliases + +- `POST /upload-file` +- `POST /create-signed-url` +- `POST /invoke-llm` +- `POST /rapid-orders/transcribe` +- `POST /rapid-orders/parse` +- `POST /verifications` +- `GET /verifications/:verificationId` +- `POST /verifications/:verificationId/review` +- `POST /verifications/:verificationId/retry` + +### Staff upload aliases + +- `POST /staff/profile/photo` +- `POST /staff/profile/documents/:documentId/upload` +- `POST /staff/profile/attire/:documentId/upload` +- `POST /staff/profile/certificates` +- `DELETE /staff/profile/certificates/:certificateId` + +## 5) Notes that matter for frontend + +- `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. +- File upload routes return a storage path plus a signed URL. Frontend uploads the file directly to storage using that URL. +- Verification routes are durable in the v2 backend and were validated live through document, attire, and certificate upload flows. + +## 6) Why this shape + +- frontend gets one host +- backend keeps reads, writes, and service helpers separated +- routing can change internally later without forcing frontend rewrites diff --git a/makefiles/backend.mk b/makefiles/backend.mk index c0032b14..058ee7f8 100644 --- a/makefiles/backend.mk +++ b/makefiles/backend.mk @@ -349,12 +349,15 @@ backend-deploy-core-v2: @test -d $(BACKEND_V2_CORE_DIR) || (echo "❌ Missing directory: $(BACKEND_V2_CORE_DIR)" && exit 1) @test -f $(BACKEND_V2_CORE_DIR)/Dockerfile || (echo "❌ Missing Dockerfile: $(BACKEND_V2_CORE_DIR)/Dockerfile" && exit 1) @gcloud builds submit $(BACKEND_V2_CORE_DIR) --tag $(BACKEND_V2_CORE_IMAGE) --project=$(GCP_PROJECT_ID) - @gcloud run deploy $(BACKEND_V2_CORE_SERVICE_NAME) \ + @EXTRA_ENV="APP_ENV=$(ENV),APP_STACK=v2,GCP_PROJECT_ID=$(GCP_PROJECT_ID),PUBLIC_BUCKET=$(BACKEND_V2_PUBLIC_BUCKET),PRIVATE_BUCKET=$(BACKEND_V2_PRIVATE_BUCKET),UPLOAD_MOCK=false,SIGNED_URL_MOCK=false,LLM_MOCK=false,LLM_LOCATION=$(BACKEND_REGION),LLM_MODEL=$(BACKEND_LLM_MODEL),LLM_TIMEOUT_MS=20000,MAX_SIGNED_URL_SECONDS=$(BACKEND_MAX_SIGNED_URL_SECONDS),LLM_RATE_LIMIT_PER_MINUTE=$(BACKEND_LLM_RATE_LIMIT_PER_MINUTE),VERIFICATION_STORE=sql,VERIFICATION_ACCESS_MODE=authenticated,VERIFICATION_REQUIRE_FILE_EXISTS=true,VERIFICATION_ATTIRE_PROVIDER=vertex,VERIFICATION_ATTIRE_MODEL=$(BACKEND_VERIFICATION_ATTIRE_MODEL),VERIFICATION_PROVIDER_TIMEOUT_MS=$(BACKEND_VERIFICATION_PROVIDER_TIMEOUT_MS),INSTANCE_CONNECTION_NAME=$(BACKEND_V2_SQL_CONNECTION_NAME),DB_NAME=$(BACKEND_V2_SQL_DATABASE),DB_USER=$(BACKEND_V2_SQL_APP_USER)"; \ + gcloud run deploy $(BACKEND_V2_CORE_SERVICE_NAME) \ --image=$(BACKEND_V2_CORE_IMAGE) \ --region=$(BACKEND_REGION) \ --project=$(GCP_PROJECT_ID) \ --service-account=$(BACKEND_V2_RUNTIME_SA_EMAIL) \ - --set-env-vars=APP_ENV=$(ENV),APP_STACK=v2,GCP_PROJECT_ID=$(GCP_PROJECT_ID),PUBLIC_BUCKET=$(BACKEND_V2_PUBLIC_BUCKET),PRIVATE_BUCKET=$(BACKEND_V2_PRIVATE_BUCKET),UPLOAD_MOCK=false,SIGNED_URL_MOCK=false,LLM_MOCK=false,LLM_LOCATION=$(BACKEND_REGION),LLM_MODEL=$(BACKEND_LLM_MODEL),LLM_TIMEOUT_MS=20000,MAX_SIGNED_URL_SECONDS=$(BACKEND_MAX_SIGNED_URL_SECONDS),LLM_RATE_LIMIT_PER_MINUTE=$(BACKEND_LLM_RATE_LIMIT_PER_MINUTE),VERIFICATION_ACCESS_MODE=authenticated,VERIFICATION_REQUIRE_FILE_EXISTS=true,VERIFICATION_ATTIRE_PROVIDER=vertex,VERIFICATION_ATTIRE_MODEL=$(BACKEND_VERIFICATION_ATTIRE_MODEL),VERIFICATION_PROVIDER_TIMEOUT_MS=$(BACKEND_VERIFICATION_PROVIDER_TIMEOUT_MS) \ + --set-env-vars=$$EXTRA_ENV \ + --set-secrets=DB_PASSWORD=$(BACKEND_V2_SQL_PASSWORD_SECRET):latest \ + --add-cloudsql-instances=$(BACKEND_V2_SQL_CONNECTION_NAME) \ $(BACKEND_V2_RUN_AUTH_FLAG) @echo "✅ Core backend v2 service deployed." @@ -438,7 +441,7 @@ backend-smoke-core-v2: exit 1; \ fi; \ TOKEN=$$(gcloud auth print-identity-token); \ - curl -fsS -H "Authorization: Bearer $$TOKEN" "$$URL/health" >/dev/null && echo "✅ Core v2 smoke check passed: $$URL/health" + curl -fsS -H "Authorization: Bearer $$TOKEN" "$$URL/readyz" >/dev/null && echo "✅ Core v2 smoke check passed: $$URL/readyz" backend-smoke-commands-v2: @echo "--> Running command v2 smoke check..."