feat(api): complete unified v2 mobile surface
This commit is contained in:
@@ -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',
|
||||
}),
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
44
backend/command-api/sql/v2/003_v2_mobile_workflows.sql
Normal file
44
backend/command-api/sql/v2/003_v2_mobile_workflows.sql
Normal file
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
301
backend/command-api/src/contracts/commands/mobile.js
Normal file
301
backend/command-api/src/contracts/commands/mobile.js
Normal file
@@ -0,0 +1,301 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const timeSlotSchema = z.object({
|
||||
start: z.string().min(1).max(20),
|
||||
end: z.string().min(1).max(20),
|
||||
});
|
||||
|
||||
const preferredLocationSchema = z.object({
|
||||
label: z.string().min(1).max(160),
|
||||
city: z.string().max(120).optional(),
|
||||
state: z.string().max(80).optional(),
|
||||
latitude: z.number().min(-90).max(90).optional(),
|
||||
longitude: z.number().min(-180).max(180).optional(),
|
||||
radiusMiles: z.number().nonnegative().optional(),
|
||||
});
|
||||
|
||||
const hhmmSchema = z.string().regex(/^\d{2}:\d{2}$/, 'Time must use HH:MM format');
|
||||
const isoDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must use YYYY-MM-DD format');
|
||||
|
||||
const shiftPositionSchema = z.object({
|
||||
roleId: z.string().uuid().optional(),
|
||||
roleCode: z.string().min(1).max(120).optional(),
|
||||
roleName: z.string().min(1).max(160).optional(),
|
||||
workerCount: z.number().int().positive().optional(),
|
||||
workersNeeded: z.number().int().positive().optional(),
|
||||
startTime: hhmmSchema,
|
||||
endTime: hhmmSchema,
|
||||
hourlyRateCents: z.number().int().nonnegative().optional(),
|
||||
payRateCents: z.number().int().nonnegative().optional(),
|
||||
billRateCents: z.number().int().nonnegative().optional(),
|
||||
lunchBreakMinutes: z.number().int().nonnegative().optional(),
|
||||
paidBreak: z.boolean().optional(),
|
||||
instantBook: z.boolean().optional(),
|
||||
metadata: z.record(z.any()).optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
if (!value.roleId && !value.roleCode && !value.roleName) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'roleId, roleCode, or roleName is required',
|
||||
path: ['roleId'],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const baseOrderCreateSchema = z.object({
|
||||
hubId: z.string().uuid(),
|
||||
vendorId: z.string().uuid().optional(),
|
||||
eventName: z.string().min(2).max(160),
|
||||
timezone: z.string().min(1).max(80).optional(),
|
||||
description: z.string().max(5000).optional(),
|
||||
notes: z.string().max(5000).optional(),
|
||||
serviceType: z.enum(['EVENT', 'CATERING', 'HOTEL', 'RESTAURANT', 'OTHER']).optional(),
|
||||
positions: z.array(shiftPositionSchema).min(1),
|
||||
metadata: z.record(z.any()).optional(),
|
||||
});
|
||||
|
||||
export const hubCreateSchema = z.object({
|
||||
name: z.string().min(1).max(160),
|
||||
fullAddress: z.string().max(300).optional(),
|
||||
placeId: z.string().max(255).optional(),
|
||||
latitude: z.number().min(-90).max(90).optional(),
|
||||
longitude: z.number().min(-180).max(180).optional(),
|
||||
street: z.string().max(160).optional(),
|
||||
city: z.string().max(120).optional(),
|
||||
state: z.string().max(80).optional(),
|
||||
country: z.string().max(80).optional(),
|
||||
zipCode: z.string().max(40).optional(),
|
||||
costCenterId: z.string().uuid().optional(),
|
||||
geofenceRadiusMeters: z.number().int().positive().optional(),
|
||||
nfcTagId: z.string().max(255).optional(),
|
||||
});
|
||||
|
||||
export const hubUpdateSchema = hubCreateSchema.extend({
|
||||
hubId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export const hubDeleteSchema = z.object({
|
||||
hubId: z.string().uuid(),
|
||||
reason: z.string().max(1000).optional(),
|
||||
});
|
||||
|
||||
export const hubAssignNfcSchema = z.object({
|
||||
hubId: z.string().uuid(),
|
||||
nfcTagId: z.string().min(1).max(255),
|
||||
});
|
||||
|
||||
export const hubAssignManagerSchema = z.object({
|
||||
hubId: z.string().uuid(),
|
||||
businessMembershipId: z.string().uuid().optional(),
|
||||
managerUserId: z.string().min(1).optional(),
|
||||
}).refine((value) => value.businessMembershipId || value.managerUserId, {
|
||||
message: 'businessMembershipId or managerUserId is required',
|
||||
});
|
||||
|
||||
export const invoiceApproveSchema = z.object({
|
||||
invoiceId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export const invoiceDisputeSchema = z.object({
|
||||
invoiceId: z.string().uuid(),
|
||||
reason: z.string().min(3).max(2000),
|
||||
});
|
||||
|
||||
export const coverageReviewSchema = z.object({
|
||||
staffId: z.string().uuid(),
|
||||
assignmentId: z.string().uuid().optional(),
|
||||
rating: z.number().int().min(1).max(5),
|
||||
markAsFavorite: z.boolean().optional(),
|
||||
issueFlags: z.array(z.string().min(1).max(80)).max(20).optional(),
|
||||
feedback: z.string().max(5000).optional(),
|
||||
});
|
||||
|
||||
export const cancelLateWorkerSchema = z.object({
|
||||
assignmentId: z.string().uuid(),
|
||||
reason: z.string().max(1000).optional(),
|
||||
});
|
||||
|
||||
export const clientOneTimeOrderSchema = baseOrderCreateSchema.extend({
|
||||
orderDate: isoDateSchema,
|
||||
});
|
||||
|
||||
export const clientRecurringOrderSchema = baseOrderCreateSchema.extend({
|
||||
startDate: isoDateSchema,
|
||||
endDate: isoDateSchema,
|
||||
recurrenceDays: z.array(z.number().int().min(0).max(6)).min(1),
|
||||
});
|
||||
|
||||
export const clientPermanentOrderSchema = baseOrderCreateSchema.extend({
|
||||
startDate: isoDateSchema,
|
||||
endDate: isoDateSchema.optional(),
|
||||
daysOfWeek: z.array(z.number().int().min(0).max(6)).min(1).optional(),
|
||||
horizonDays: z.number().int().min(7).max(180).optional(),
|
||||
});
|
||||
|
||||
export const clientOrderEditSchema = z.object({
|
||||
orderId: z.string().uuid(),
|
||||
orderType: z.enum(['ONE_TIME', 'RECURRING', 'PERMANENT']).optional(),
|
||||
hubId: z.string().uuid().optional(),
|
||||
vendorId: z.string().uuid().optional(),
|
||||
eventName: z.string().min(2).max(160).optional(),
|
||||
orderDate: isoDateSchema.optional(),
|
||||
startDate: isoDateSchema.optional(),
|
||||
endDate: isoDateSchema.optional(),
|
||||
recurrenceDays: z.array(z.number().int().min(0).max(6)).min(1).optional(),
|
||||
daysOfWeek: z.array(z.number().int().min(0).max(6)).min(1).optional(),
|
||||
timezone: z.string().min(1).max(80).optional(),
|
||||
description: z.string().max(5000).optional(),
|
||||
notes: z.string().max(5000).optional(),
|
||||
serviceType: z.enum(['EVENT', 'CATERING', 'HOTEL', 'RESTAURANT', 'OTHER']).optional(),
|
||||
positions: z.array(shiftPositionSchema).min(1).optional(),
|
||||
metadata: z.record(z.any()).optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
const keys = Object.keys(value).filter((key) => key !== 'orderId');
|
||||
if (keys.length === 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'At least one field must be provided to create an edited order copy',
|
||||
path: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const clientOrderCancelSchema = z.object({
|
||||
orderId: z.string().uuid(),
|
||||
reason: z.string().max(1000).optional(),
|
||||
metadata: z.record(z.any()).optional(),
|
||||
});
|
||||
|
||||
export const availabilityDayUpdateSchema = z.object({
|
||||
dayOfWeek: z.number().int().min(0).max(6),
|
||||
availabilityStatus: z.enum(['AVAILABLE', 'UNAVAILABLE', 'PARTIAL']),
|
||||
slots: z.array(timeSlotSchema).max(8).optional(),
|
||||
metadata: z.record(z.any()).optional(),
|
||||
});
|
||||
|
||||
export const availabilityQuickSetSchema = z.object({
|
||||
startDate: z.string().datetime().optional(),
|
||||
endDate: z.string().datetime().optional(),
|
||||
quickSetType: z.enum(['all', 'weekdays', 'weekends', 'clear']),
|
||||
slots: z.array(timeSlotSchema).max(8).optional(),
|
||||
});
|
||||
|
||||
export const shiftApplySchema = z.object({
|
||||
shiftId: z.string().uuid(),
|
||||
roleId: z.string().uuid().optional(),
|
||||
instantBook: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const shiftDecisionSchema = z.object({
|
||||
shiftId: z.string().uuid(),
|
||||
reason: z.string().max(1000).optional(),
|
||||
});
|
||||
|
||||
export const staffClockInSchema = z.object({
|
||||
assignmentId: z.string().uuid().optional(),
|
||||
shiftId: z.string().uuid().optional(),
|
||||
sourceType: z.enum(['NFC', 'GEO', 'QR', 'MANUAL', 'SYSTEM']).optional(),
|
||||
sourceReference: z.string().max(255).optional(),
|
||||
nfcTagId: z.string().max(255).optional(),
|
||||
deviceId: z.string().max(255).optional(),
|
||||
latitude: z.number().min(-90).max(90).optional(),
|
||||
longitude: z.number().min(-180).max(180).optional(),
|
||||
accuracyMeters: z.number().int().nonnegative().optional(),
|
||||
capturedAt: z.string().datetime().optional(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
rawPayload: z.record(z.any()).optional(),
|
||||
}).refine((value) => value.assignmentId || value.shiftId, {
|
||||
message: 'assignmentId or shiftId is required',
|
||||
});
|
||||
|
||||
export const staffClockOutSchema = z.object({
|
||||
assignmentId: z.string().uuid().optional(),
|
||||
shiftId: z.string().uuid().optional(),
|
||||
applicationId: z.string().uuid().optional(),
|
||||
sourceType: z.enum(['NFC', 'GEO', 'QR', 'MANUAL', 'SYSTEM']).optional(),
|
||||
sourceReference: z.string().max(255).optional(),
|
||||
nfcTagId: z.string().max(255).optional(),
|
||||
deviceId: z.string().max(255).optional(),
|
||||
latitude: z.number().min(-90).max(90).optional(),
|
||||
longitude: z.number().min(-180).max(180).optional(),
|
||||
accuracyMeters: z.number().int().nonnegative().optional(),
|
||||
capturedAt: z.string().datetime().optional(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
breakMinutes: z.number().int().nonnegative().optional(),
|
||||
rawPayload: z.record(z.any()).optional(),
|
||||
}).refine((value) => value.assignmentId || value.shiftId || value.applicationId, {
|
||||
message: 'assignmentId, shiftId, or applicationId is required',
|
||||
});
|
||||
|
||||
export const staffProfileSetupSchema = z.object({
|
||||
fullName: z.string().min(2).max(160),
|
||||
bio: z.string().max(5000).optional(),
|
||||
email: z.string().email().optional(),
|
||||
phoneNumber: z.string().min(6).max(40),
|
||||
preferredLocations: z.array(preferredLocationSchema).max(20).optional(),
|
||||
maxDistanceMiles: z.number().nonnegative().max(500).optional(),
|
||||
industries: z.array(z.string().min(1).max(80)).max(30).optional(),
|
||||
skills: z.array(z.string().min(1).max(80)).max(50).optional(),
|
||||
primaryRole: z.string().max(120).optional(),
|
||||
tenantId: z.string().uuid().optional(),
|
||||
vendorId: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
export const personalInfoUpdateSchema = z.object({
|
||||
firstName: z.string().min(1).max(80).optional(),
|
||||
lastName: z.string().min(1).max(80).optional(),
|
||||
bio: z.string().max(5000).optional(),
|
||||
preferredLocations: z.array(preferredLocationSchema).max(20).optional(),
|
||||
maxDistanceMiles: z.number().nonnegative().max(500).optional(),
|
||||
email: z.string().email().optional(),
|
||||
phone: z.string().min(6).max(40).optional(),
|
||||
displayName: z.string().min(2).max(160).optional(),
|
||||
});
|
||||
|
||||
export const profileExperienceSchema = z.object({
|
||||
industries: z.array(z.string().min(1).max(80)).max(30).optional(),
|
||||
skills: z.array(z.string().min(1).max(80)).max(50).optional(),
|
||||
primaryRole: z.string().max(120).optional(),
|
||||
});
|
||||
|
||||
export const preferredLocationsUpdateSchema = z.object({
|
||||
preferredLocations: z.array(preferredLocationSchema).max(20),
|
||||
maxDistanceMiles: z.number().nonnegative().max(500).optional(),
|
||||
});
|
||||
|
||||
export const emergencyContactCreateSchema = z.object({
|
||||
fullName: z.string().min(2).max(160),
|
||||
phone: z.string().min(6).max(40),
|
||||
relationshipType: z.string().min(1).max(120),
|
||||
isPrimary: z.boolean().optional(),
|
||||
metadata: z.record(z.any()).optional(),
|
||||
});
|
||||
|
||||
export const emergencyContactUpdateSchema = emergencyContactCreateSchema.partial().extend({
|
||||
contactId: z.string().uuid(),
|
||||
});
|
||||
|
||||
const taxFormFieldsSchema = z.record(z.any());
|
||||
|
||||
export const taxFormDraftSchema = z.object({
|
||||
formType: z.enum(['I9', 'W4']),
|
||||
fields: taxFormFieldsSchema,
|
||||
});
|
||||
|
||||
export const taxFormSubmitSchema = z.object({
|
||||
formType: z.enum(['I9', 'W4']),
|
||||
fields: taxFormFieldsSchema,
|
||||
});
|
||||
|
||||
export const bankAccountCreateSchema = z.object({
|
||||
bankName: z.string().min(2).max(160),
|
||||
accountNumber: z.string().min(4).max(34),
|
||||
routingNumber: z.string().min(4).max(20),
|
||||
accountType: z.string()
|
||||
.transform((value) => value.trim().toUpperCase())
|
||||
.pipe(z.enum(['CHECKING', 'SAVINGS'])),
|
||||
});
|
||||
|
||||
export const privacyUpdateSchema = z.object({
|
||||
profileVisible: z.boolean(),
|
||||
});
|
||||
@@ -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(),
|
||||
|
||||
412
backend/command-api/src/routes/mobile.js
Normal file
412
backend/command-api/src/routes/mobile.js
Normal file
@@ -0,0 +1,412 @@
|
||||
import { Router } from 'express';
|
||||
import { AppError } from '../lib/errors.js';
|
||||
import { requireAuth, requirePolicy } from '../middleware/auth.js';
|
||||
import { requireIdempotencyKey } from '../middleware/idempotency.js';
|
||||
import { buildIdempotencyKey, readIdempotentResult, writeIdempotentResult } from '../services/idempotency-store.js';
|
||||
import {
|
||||
addStaffBankAccount,
|
||||
approveInvoice,
|
||||
applyForShift,
|
||||
assignHubManager,
|
||||
assignHubNfc,
|
||||
cancelLateWorker,
|
||||
cancelClientOrder,
|
||||
createEmergencyContact,
|
||||
createClientOneTimeOrder,
|
||||
createClientPermanentOrder,
|
||||
createClientRecurringOrder,
|
||||
createEditedOrderCopy,
|
||||
createHub,
|
||||
declinePendingShift,
|
||||
disputeInvoice,
|
||||
quickSetStaffAvailability,
|
||||
rateWorkerFromCoverage,
|
||||
requestShiftSwap,
|
||||
saveTaxFormDraft,
|
||||
setupStaffProfile,
|
||||
staffClockIn,
|
||||
staffClockOut,
|
||||
submitTaxForm,
|
||||
updateEmergencyContact,
|
||||
updateHub,
|
||||
updatePersonalInfo,
|
||||
updatePreferredLocations,
|
||||
updatePrivacyVisibility,
|
||||
updateProfileExperience,
|
||||
updateStaffAvailabilityDay,
|
||||
deleteHub,
|
||||
acceptPendingShift,
|
||||
} from '../services/mobile-command-service.js';
|
||||
import {
|
||||
availabilityDayUpdateSchema,
|
||||
availabilityQuickSetSchema,
|
||||
bankAccountCreateSchema,
|
||||
cancelLateWorkerSchema,
|
||||
clientOneTimeOrderSchema,
|
||||
clientOrderCancelSchema,
|
||||
clientOrderEditSchema,
|
||||
clientPermanentOrderSchema,
|
||||
clientRecurringOrderSchema,
|
||||
coverageReviewSchema,
|
||||
emergencyContactCreateSchema,
|
||||
emergencyContactUpdateSchema,
|
||||
hubAssignManagerSchema,
|
||||
hubAssignNfcSchema,
|
||||
hubCreateSchema,
|
||||
hubDeleteSchema,
|
||||
hubUpdateSchema,
|
||||
invoiceApproveSchema,
|
||||
invoiceDisputeSchema,
|
||||
personalInfoUpdateSchema,
|
||||
preferredLocationsUpdateSchema,
|
||||
privacyUpdateSchema,
|
||||
profileExperienceSchema,
|
||||
shiftApplySchema,
|
||||
shiftDecisionSchema,
|
||||
staffClockInSchema,
|
||||
staffClockOutSchema,
|
||||
staffProfileSetupSchema,
|
||||
taxFormDraftSchema,
|
||||
taxFormSubmitSchema,
|
||||
} from '../contracts/commands/mobile.js';
|
||||
|
||||
const defaultHandlers = {
|
||||
acceptPendingShift,
|
||||
addStaffBankAccount,
|
||||
approveInvoice,
|
||||
applyForShift,
|
||||
assignHubManager,
|
||||
assignHubNfc,
|
||||
cancelLateWorker,
|
||||
cancelClientOrder,
|
||||
createEmergencyContact,
|
||||
createClientOneTimeOrder,
|
||||
createClientPermanentOrder,
|
||||
createClientRecurringOrder,
|
||||
createEditedOrderCopy,
|
||||
createHub,
|
||||
declinePendingShift,
|
||||
disputeInvoice,
|
||||
quickSetStaffAvailability,
|
||||
rateWorkerFromCoverage,
|
||||
requestShiftSwap,
|
||||
saveTaxFormDraft,
|
||||
setupStaffProfile,
|
||||
staffClockIn,
|
||||
staffClockOut,
|
||||
submitTaxForm,
|
||||
updateEmergencyContact,
|
||||
updateHub,
|
||||
updatePersonalInfo,
|
||||
updatePreferredLocations,
|
||||
updatePrivacyVisibility,
|
||||
updateProfileExperience,
|
||||
updateStaffAvailabilityDay,
|
||||
deleteHub,
|
||||
};
|
||||
|
||||
function parseBody(schema, body) {
|
||||
const parsed = schema.safeParse(body || {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError('VALIDATION_ERROR', 'Invalid request payload', 400, {
|
||||
issues: parsed.error.issues,
|
||||
});
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
async function runIdempotentCommand(req, res, work) {
|
||||
const route = `${req.baseUrl}${req.route.path}`;
|
||||
const compositeKey = buildIdempotencyKey({
|
||||
userId: req.actor.uid,
|
||||
route,
|
||||
idempotencyKey: req.idempotencyKey,
|
||||
});
|
||||
|
||||
const existing = await readIdempotentResult(compositeKey);
|
||||
if (existing) {
|
||||
return res.status(existing.statusCode).json(existing.payload);
|
||||
}
|
||||
|
||||
const payload = await work();
|
||||
const responsePayload = {
|
||||
...payload,
|
||||
idempotencyKey: req.idempotencyKey,
|
||||
requestId: req.requestId,
|
||||
};
|
||||
const persisted = await writeIdempotentResult({
|
||||
compositeKey,
|
||||
userId: req.actor.uid,
|
||||
route,
|
||||
idempotencyKey: req.idempotencyKey,
|
||||
payload: responsePayload,
|
||||
statusCode: 200,
|
||||
});
|
||||
return res.status(persisted.statusCode).json(persisted.payload);
|
||||
}
|
||||
|
||||
function mobileCommand(route, { schema, policyAction, resource, handler, paramShape }) {
|
||||
return [
|
||||
route,
|
||||
requireAuth,
|
||||
requireIdempotencyKey,
|
||||
requirePolicy(policyAction, resource),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const body = typeof paramShape === 'function'
|
||||
? paramShape(req)
|
||||
: req.body;
|
||||
const payload = parseBody(schema, body);
|
||||
return await runIdempotentCommand(req, res, () => handler(req.actor, payload));
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function createMobileCommandsRouter(handlers = defaultHandlers) {
|
||||
const router = Router();
|
||||
|
||||
router.post(...mobileCommand('/client/orders/one-time', {
|
||||
schema: clientOneTimeOrderSchema,
|
||||
policyAction: 'orders.create',
|
||||
resource: 'order',
|
||||
handler: handlers.createClientOneTimeOrder,
|
||||
}));
|
||||
|
||||
router.post(...mobileCommand('/client/orders/recurring', {
|
||||
schema: clientRecurringOrderSchema,
|
||||
policyAction: 'orders.create',
|
||||
resource: 'order',
|
||||
handler: handlers.createClientRecurringOrder,
|
||||
}));
|
||||
|
||||
router.post(...mobileCommand('/client/orders/permanent', {
|
||||
schema: clientPermanentOrderSchema,
|
||||
policyAction: 'orders.create',
|
||||
resource: 'order',
|
||||
handler: handlers.createClientPermanentOrder,
|
||||
}));
|
||||
|
||||
router.post(...mobileCommand('/client/orders/:orderId/edit', {
|
||||
schema: clientOrderEditSchema,
|
||||
policyAction: 'orders.update',
|
||||
resource: 'order',
|
||||
handler: handlers.createEditedOrderCopy,
|
||||
paramShape: (req) => ({ ...req.body, orderId: req.params.orderId }),
|
||||
}));
|
||||
|
||||
router.post(...mobileCommand('/client/orders/:orderId/cancel', {
|
||||
schema: clientOrderCancelSchema,
|
||||
policyAction: 'orders.cancel',
|
||||
resource: 'order',
|
||||
handler: handlers.cancelClientOrder,
|
||||
paramShape: (req) => ({ ...req.body, orderId: req.params.orderId }),
|
||||
}));
|
||||
|
||||
router.post(...mobileCommand('/client/hubs', {
|
||||
schema: hubCreateSchema,
|
||||
policyAction: 'client.hubs.create',
|
||||
resource: 'hub',
|
||||
handler: handlers.createHub,
|
||||
}));
|
||||
|
||||
router.put(...mobileCommand('/client/hubs/:hubId', {
|
||||
schema: hubUpdateSchema,
|
||||
policyAction: 'client.hubs.update',
|
||||
resource: 'hub',
|
||||
handler: handlers.updateHub,
|
||||
paramShape: (req) => ({ ...req.body, hubId: req.params.hubId }),
|
||||
}));
|
||||
|
||||
router.delete(...mobileCommand('/client/hubs/:hubId', {
|
||||
schema: hubDeleteSchema,
|
||||
policyAction: 'client.hubs.delete',
|
||||
resource: 'hub',
|
||||
handler: handlers.deleteHub,
|
||||
paramShape: (req) => ({ ...req.body, hubId: req.params.hubId }),
|
||||
}));
|
||||
|
||||
router.post(...mobileCommand('/client/hubs/:hubId/assign-nfc', {
|
||||
schema: hubAssignNfcSchema,
|
||||
policyAction: 'client.hubs.update',
|
||||
resource: 'hub',
|
||||
handler: handlers.assignHubNfc,
|
||||
paramShape: (req) => ({ ...req.body, hubId: req.params.hubId }),
|
||||
}));
|
||||
|
||||
router.post(...mobileCommand('/client/hubs/:hubId/managers', {
|
||||
schema: hubAssignManagerSchema,
|
||||
policyAction: 'client.hubs.update',
|
||||
resource: 'hub',
|
||||
handler: handlers.assignHubManager,
|
||||
paramShape: (req) => ({ ...req.body, hubId: req.params.hubId }),
|
||||
}));
|
||||
|
||||
router.post(...mobileCommand('/client/billing/invoices/:invoiceId/approve', {
|
||||
schema: invoiceApproveSchema,
|
||||
policyAction: 'client.billing.write',
|
||||
resource: 'invoice',
|
||||
handler: handlers.approveInvoice,
|
||||
paramShape: (req) => ({ invoiceId: req.params.invoiceId }),
|
||||
}));
|
||||
|
||||
router.post(...mobileCommand('/client/billing/invoices/:invoiceId/dispute', {
|
||||
schema: invoiceDisputeSchema,
|
||||
policyAction: 'client.billing.write',
|
||||
resource: 'invoice',
|
||||
handler: handlers.disputeInvoice,
|
||||
paramShape: (req) => ({ ...req.body, invoiceId: req.params.invoiceId }),
|
||||
}));
|
||||
|
||||
router.post(...mobileCommand('/client/coverage/reviews', {
|
||||
schema: coverageReviewSchema,
|
||||
policyAction: 'client.coverage.write',
|
||||
resource: 'staff_review',
|
||||
handler: handlers.rateWorkerFromCoverage,
|
||||
}));
|
||||
|
||||
router.post(...mobileCommand('/client/coverage/late-workers/:assignmentId/cancel', {
|
||||
schema: cancelLateWorkerSchema,
|
||||
policyAction: 'client.coverage.write',
|
||||
resource: 'assignment',
|
||||
handler: handlers.cancelLateWorker,
|
||||
paramShape: (req) => ({ ...req.body, assignmentId: req.params.assignmentId }),
|
||||
}));
|
||||
|
||||
router.post(...mobileCommand('/staff/profile/setup', {
|
||||
schema: staffProfileSetupSchema,
|
||||
policyAction: 'staff.profile.write',
|
||||
resource: 'staff',
|
||||
handler: handlers.setupStaffProfile,
|
||||
}));
|
||||
|
||||
router.post(...mobileCommand('/staff/clock-in', {
|
||||
schema: staffClockInSchema,
|
||||
policyAction: 'attendance.clock-in',
|
||||
resource: 'attendance',
|
||||
handler: handlers.staffClockIn,
|
||||
}));
|
||||
|
||||
router.post(...mobileCommand('/staff/clock-out', {
|
||||
schema: staffClockOutSchema,
|
||||
policyAction: 'attendance.clock-out',
|
||||
resource: 'attendance',
|
||||
handler: handlers.staffClockOut,
|
||||
}));
|
||||
|
||||
router.put(...mobileCommand('/staff/availability', {
|
||||
schema: availabilityDayUpdateSchema,
|
||||
policyAction: 'staff.availability.write',
|
||||
resource: 'staff',
|
||||
handler: handlers.updateStaffAvailabilityDay,
|
||||
}));
|
||||
|
||||
router.post(...mobileCommand('/staff/availability/quick-set', {
|
||||
schema: availabilityQuickSetSchema,
|
||||
policyAction: 'staff.availability.write',
|
||||
resource: 'staff',
|
||||
handler: handlers.quickSetStaffAvailability,
|
||||
}));
|
||||
|
||||
router.post(...mobileCommand('/staff/shifts/:shiftId/apply', {
|
||||
schema: shiftApplySchema,
|
||||
policyAction: 'staff.shifts.apply',
|
||||
resource: 'shift',
|
||||
handler: handlers.applyForShift,
|
||||
paramShape: (req) => ({ ...req.body, shiftId: req.params.shiftId }),
|
||||
}));
|
||||
|
||||
router.post(...mobileCommand('/staff/shifts/:shiftId/accept', {
|
||||
schema: shiftDecisionSchema,
|
||||
policyAction: 'staff.shifts.accept',
|
||||
resource: 'shift',
|
||||
handler: handlers.acceptPendingShift,
|
||||
paramShape: (req) => ({ ...req.body, shiftId: req.params.shiftId }),
|
||||
}));
|
||||
|
||||
router.post(...mobileCommand('/staff/shifts/:shiftId/decline', {
|
||||
schema: shiftDecisionSchema,
|
||||
policyAction: 'staff.shifts.decline',
|
||||
resource: 'shift',
|
||||
handler: handlers.declinePendingShift,
|
||||
paramShape: (req) => ({ ...req.body, shiftId: req.params.shiftId }),
|
||||
}));
|
||||
|
||||
router.post(...mobileCommand('/staff/shifts/:shiftId/request-swap', {
|
||||
schema: shiftDecisionSchema,
|
||||
policyAction: 'staff.shifts.swap',
|
||||
resource: 'shift',
|
||||
handler: handlers.requestShiftSwap,
|
||||
paramShape: (req) => ({ ...req.body, shiftId: req.params.shiftId }),
|
||||
}));
|
||||
|
||||
router.put(...mobileCommand('/staff/profile/personal-info', {
|
||||
schema: personalInfoUpdateSchema,
|
||||
policyAction: 'staff.profile.write',
|
||||
resource: 'staff',
|
||||
handler: handlers.updatePersonalInfo,
|
||||
}));
|
||||
|
||||
router.put(...mobileCommand('/staff/profile/experience', {
|
||||
schema: profileExperienceSchema,
|
||||
policyAction: 'staff.profile.write',
|
||||
resource: 'staff',
|
||||
handler: handlers.updateProfileExperience,
|
||||
}));
|
||||
|
||||
router.put(...mobileCommand('/staff/profile/locations', {
|
||||
schema: preferredLocationsUpdateSchema,
|
||||
policyAction: 'staff.profile.write',
|
||||
resource: 'staff',
|
||||
handler: handlers.updatePreferredLocations,
|
||||
}));
|
||||
|
||||
router.post(...mobileCommand('/staff/profile/emergency-contacts', {
|
||||
schema: emergencyContactCreateSchema,
|
||||
policyAction: 'staff.profile.write',
|
||||
resource: 'staff',
|
||||
handler: handlers.createEmergencyContact,
|
||||
}));
|
||||
|
||||
router.put(...mobileCommand('/staff/profile/emergency-contacts/:contactId', {
|
||||
schema: emergencyContactUpdateSchema,
|
||||
policyAction: 'staff.profile.write',
|
||||
resource: 'staff',
|
||||
handler: handlers.updateEmergencyContact,
|
||||
paramShape: (req) => ({ ...req.body, contactId: req.params.contactId }),
|
||||
}));
|
||||
|
||||
router.put(...mobileCommand('/staff/profile/tax-forms/:formType', {
|
||||
schema: taxFormDraftSchema,
|
||||
policyAction: 'staff.profile.write',
|
||||
resource: 'staff_document',
|
||||
handler: handlers.saveTaxFormDraft,
|
||||
paramShape: (req) => ({ ...req.body, formType: `${req.params.formType}`.toUpperCase() }),
|
||||
}));
|
||||
|
||||
router.post(...mobileCommand('/staff/profile/tax-forms/:formType/submit', {
|
||||
schema: taxFormSubmitSchema,
|
||||
policyAction: 'staff.profile.write',
|
||||
resource: 'staff_document',
|
||||
handler: handlers.submitTaxForm,
|
||||
paramShape: (req) => ({ ...req.body, formType: `${req.params.formType}`.toUpperCase() }),
|
||||
}));
|
||||
|
||||
router.post(...mobileCommand('/staff/profile/bank-accounts', {
|
||||
schema: bankAccountCreateSchema,
|
||||
policyAction: 'staff.profile.write',
|
||||
resource: 'account',
|
||||
handler: handlers.addStaffBankAccount,
|
||||
}));
|
||||
|
||||
router.put(...mobileCommand('/staff/profile/privacy', {
|
||||
schema: privacyUpdateSchema,
|
||||
policyAction: 'staff.profile.write',
|
||||
resource: 'staff',
|
||||
handler: handlers.updatePrivacyVisibility,
|
||||
}));
|
||||
|
||||
return router;
|
||||
}
|
||||
111
backend/command-api/src/services/actor-context.js
Normal file
111
backend/command-api/src/services/actor-context.js
Normal file
@@ -0,0 +1,111 @@
|
||||
import { AppError } from '../lib/errors.js';
|
||||
import { query } from './db.js';
|
||||
|
||||
export async function loadActorContext(uid) {
|
||||
const [userResult, tenantResult, businessResult, vendorResult, staffResult] = await Promise.all([
|
||||
query(
|
||||
`
|
||||
SELECT id AS "userId", email, display_name AS "displayName", phone, status
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
`,
|
||||
[uid]
|
||||
),
|
||||
query(
|
||||
`
|
||||
SELECT tm.id AS "membershipId",
|
||||
tm.tenant_id AS "tenantId",
|
||||
tm.base_role AS role,
|
||||
t.name AS "tenantName",
|
||||
t.slug AS "tenantSlug"
|
||||
FROM tenant_memberships tm
|
||||
JOIN tenants t ON t.id = tm.tenant_id
|
||||
WHERE tm.user_id = $1
|
||||
AND tm.membership_status = 'ACTIVE'
|
||||
ORDER BY tm.created_at ASC
|
||||
LIMIT 1
|
||||
`,
|
||||
[uid]
|
||||
),
|
||||
query(
|
||||
`
|
||||
SELECT bm.id AS "membershipId",
|
||||
bm.business_id AS "businessId",
|
||||
bm.business_role AS role,
|
||||
b.business_name AS "businessName",
|
||||
b.slug AS "businessSlug",
|
||||
bm.tenant_id AS "tenantId"
|
||||
FROM business_memberships bm
|
||||
JOIN businesses b ON b.id = bm.business_id
|
||||
WHERE bm.user_id = $1
|
||||
AND bm.membership_status = 'ACTIVE'
|
||||
ORDER BY bm.created_at ASC
|
||||
LIMIT 1
|
||||
`,
|
||||
[uid]
|
||||
),
|
||||
query(
|
||||
`
|
||||
SELECT vm.id AS "membershipId",
|
||||
vm.vendor_id AS "vendorId",
|
||||
vm.vendor_role AS role,
|
||||
v.company_name AS "vendorName",
|
||||
v.slug AS "vendorSlug",
|
||||
vm.tenant_id AS "tenantId"
|
||||
FROM vendor_memberships vm
|
||||
JOIN vendors v ON v.id = vm.vendor_id
|
||||
WHERE vm.user_id = $1
|
||||
AND vm.membership_status = 'ACTIVE'
|
||||
ORDER BY vm.created_at ASC
|
||||
LIMIT 1
|
||||
`,
|
||||
[uid]
|
||||
),
|
||||
query(
|
||||
`
|
||||
SELECT s.id AS "staffId",
|
||||
s.tenant_id AS "tenantId",
|
||||
s.full_name AS "fullName",
|
||||
s.email,
|
||||
s.phone,
|
||||
s.primary_role AS "primaryRole",
|
||||
s.onboarding_status AS "onboardingStatus",
|
||||
s.status,
|
||||
s.metadata,
|
||||
w.id AS "workforceId",
|
||||
w.vendor_id AS "vendorId",
|
||||
w.workforce_number AS "workforceNumber"
|
||||
FROM staffs s
|
||||
LEFT JOIN workforce w ON w.staff_id = s.id
|
||||
WHERE s.user_id = $1
|
||||
ORDER BY s.created_at ASC
|
||||
LIMIT 1
|
||||
`,
|
||||
[uid]
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
user: userResult.rows[0] || null,
|
||||
tenant: tenantResult.rows[0] || null,
|
||||
business: businessResult.rows[0] || null,
|
||||
vendor: vendorResult.rows[0] || null,
|
||||
staff: staffResult.rows[0] || null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function requireClientContext(uid) {
|
||||
const context = await loadActorContext(uid);
|
||||
if (!context.user || !context.tenant || !context.business) {
|
||||
throw new AppError('FORBIDDEN', 'Client business context is required for this route', 403, { uid });
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export async function requireStaffContext(uid) {
|
||||
const context = await loadActorContext(uid);
|
||||
if (!context.user || !context.tenant || !context.staff) {
|
||||
throw new AppError('FORBIDDEN', 'Staff context is required for this route', 403, { uid });
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
2348
backend/command-api/src/services/mobile-command-service.js
Normal file
2348
backend/command-api/src/services/mobile-command-service.js
Normal file
File diff suppressed because it is too large
Load Diff
233
backend/command-api/test/mobile-routes.test.js
Normal file
233
backend/command-api/test/mobile-routes.test.js
Normal file
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user