diff --git a/backend/command-api/scripts/seed-v2-demo-data.mjs b/backend/command-api/scripts/seed-v2-demo-data.mjs index a2cd2dba..836a2382 100644 --- a/backend/command-api/scripts/seed-v2-demo-data.mjs +++ b/backend/command-api/scripts/seed-v2-demo-data.mjs @@ -256,6 +256,36 @@ async function main() { [fixture.benefits.commuter.id, fixture.tenant.id, fixture.staff.ana.id, fixture.benefits.commuter.title] ); + await client.query( + ` + INSERT INTO staff_benefit_history ( + id, tenant_id, staff_id, benefit_id, benefit_type, title, status, + effective_at, ended_at, tracked_hours, target_hours, notes, metadata + ) + VALUES + ( + $1, $3, $4, $5, 'COMMUTER', $6, 'PENDING', + NOW() - INTERVAL '45 days', NOW() - INTERVAL '10 days', 18, 40, + 'Hours were below threshold for payout window.', + '{"source":"seed-v2-demo","period":"previous"}'::jsonb + ), + ( + $2, $3, $4, $5, 'COMMUTER', $6, 'ACTIVE', + NOW() - INTERVAL '9 days', NULL, 32, 40, + 'Current active commuter stipend tracking.', + '{"source":"seed-v2-demo","period":"current"}'::jsonb + ) + `, + [ + fixture.benefitHistory.commuterPending.id, + fixture.benefitHistory.commuterActive.id, + fixture.tenant.id, + fixture.staff.ana.id, + fixture.benefits.commuter.id, + fixture.benefits.commuter.title, + ] + ); + await client.query( ` INSERT INTO emergency_contacts ( diff --git a/backend/command-api/scripts/v2-demo-fixture.mjs b/backend/command-api/scripts/v2-demo-fixture.mjs index 3fd31310..78d0a392 100644 --- a/backend/command-api/scripts/v2-demo-fixture.mjs +++ b/backend/command-api/scripts/v2-demo-fixture.mjs @@ -99,6 +99,14 @@ export const V2DemoFixture = { title: 'Commuter Support', }, }, + benefitHistory: { + commuterActive: { + id: '9e46729a-ff53-4d1b-9110-7ee5c38a9001', + }, + commuterPending: { + id: '9e46729a-ff53-4d1b-9110-7ee5c38a9002', + }, + }, orders: { open: { id: 'b6132d7a-45c3-4879-b349-46b2fd518001', diff --git a/backend/command-api/sql/v2/007_v2_staff_blocks_and_benefit_history.sql b/backend/command-api/sql/v2/007_v2_staff_blocks_and_benefit_history.sql new file mode 100644 index 00000000..f7da362a --- /dev/null +++ b/backend/command-api/sql/v2/007_v2_staff_blocks_and_benefit_history.sql @@ -0,0 +1,41 @@ +CREATE TABLE IF NOT EXISTS staff_blocks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + business_id UUID NOT NULL REFERENCES businesses(id) ON DELETE CASCADE, + staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE CASCADE, + created_by_user_id TEXT REFERENCES users(id) ON DELETE SET NULL, + reason TEXT, + issue_flags JSONB NOT NULL DEFAULT '[]'::jsonb, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_staff_blocks_business_staff + ON staff_blocks (business_id, staff_id); + +CREATE INDEX IF NOT EXISTS idx_staff_blocks_business_created_at + ON staff_blocks (business_id, created_at DESC); + +CREATE TABLE IF NOT EXISTS staff_benefit_history ( + 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, + benefit_id UUID REFERENCES staff_benefits(id) ON DELETE SET NULL, + benefit_type TEXT NOT NULL, + title TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'ACTIVE', + effective_at TIMESTAMPTZ NOT NULL, + ended_at TIMESTAMPTZ, + tracked_hours INTEGER NOT NULL DEFAULT 0, + target_hours INTEGER, + notes TEXT, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_staff_benefit_history_staff_effective_at + ON staff_benefit_history (staff_id, effective_at DESC); + +CREATE INDEX IF NOT EXISTS idx_staff_benefit_history_tenant_benefit_type + ON staff_benefit_history (tenant_id, benefit_type, effective_at DESC); diff --git a/backend/command-api/src/contracts/commands/mobile.js b/backend/command-api/src/contracts/commands/mobile.js index 5a81fd30..20b179fb 100644 --- a/backend/command-api/src/contracts/commands/mobile.js +++ b/backend/command-api/src/contracts/commands/mobile.js @@ -97,6 +97,16 @@ export const hubAssignManagerSchema = z.object({ message: 'businessMembershipId or managerUserId is required', }); +export const shiftManagerCreateSchema = z.object({ + hubId: z.string().uuid().optional(), + email: z.string().email(), + firstName: z.string().min(1).max(120), + lastName: z.string().min(1).max(120), + phone: z.string().min(7).max(40).optional(), + role: z.enum(['manager', 'viewer']).optional(), + metadata: z.record(z.any()).optional(), +}); + export const invoiceApproveSchema = z.object({ invoiceId: z.string().uuid(), }); @@ -111,6 +121,7 @@ export const coverageReviewSchema = z.object({ assignmentId: z.string().uuid().optional(), rating: z.number().int().min(1).max(5), markAsFavorite: z.boolean().optional(), + markAsBlocked: z.boolean().optional(), issueFlags: z.array(z.string().min(1).max(80)).max(20).optional(), feedback: z.string().max(5000).optional(), }); diff --git a/backend/command-api/src/routes/mobile.js b/backend/command-api/src/routes/mobile.js index bc3cf9db..de62edd2 100644 --- a/backend/command-api/src/routes/mobile.js +++ b/backend/command-api/src/routes/mobile.js @@ -17,6 +17,7 @@ import { createClientRecurringOrder, createEditedOrderCopy, createHub, + createShiftManager, declinePendingShift, disputeInvoice, quickSetStaffAvailability, @@ -69,6 +70,7 @@ import { profileExperienceSchema, pushTokenDeleteSchema, pushTokenRegisterSchema, + shiftManagerCreateSchema, shiftApplySchema, shiftDecisionSchema, shiftSubmitApprovalSchema, @@ -95,6 +97,7 @@ const defaultHandlers = { createClientRecurringOrder, createEditedOrderCopy, createHub, + createShiftManager, declinePendingShift, disputeInvoice, quickSetStaffAvailability, @@ -260,6 +263,13 @@ export function createMobileCommandsRouter(handlers = defaultHandlers) { paramShape: (req) => ({ ...req.body, hubId: req.params.hubId }), })); + router.post(...mobileCommand('/client/shift-managers', { + schema: shiftManagerCreateSchema, + policyAction: 'client.hubs.update', + resource: 'hub_manager', + handler: handlers.createShiftManager, + })); + router.post(...mobileCommand('/client/billing/invoices/:invoiceId/approve', { schema: invoiceApproveSchema, policyAction: 'client.billing.write', diff --git a/backend/command-api/src/services/command-service.js b/backend/command-api/src/services/command-service.js index a9cce39b..ba257a12 100644 --- a/backend/command-api/src/services/command-service.js +++ b/backend/command-api/src/services/command-service.js @@ -44,6 +44,30 @@ async function ensureActorUser(client, actor) { ); } +async function ensureStaffNotBlockedByBusiness(client, { tenantId, businessId, staffId }) { + const blocked = await client.query( + ` + SELECT id, reason, issue_flags + FROM staff_blocks + WHERE tenant_id = $1 + AND business_id = $2 + AND staff_id = $3 + LIMIT 1 + `, + [tenantId, businessId, staffId] + ); + + if (blocked.rowCount > 0) { + throw new AppError('STAFF_BLOCKED', 'Staff is blocked from future shift assignments for this business', 409, { + businessId, + staffId, + blockId: blocked.rows[0].id, + reason: blocked.rows[0].reason || null, + issueFlags: Array.isArray(blocked.rows[0].issue_flags) ? blocked.rows[0].issue_flags : [], + }); + } +} + async function insertDomainEvent(client, { tenantId, aggregateType, @@ -986,6 +1010,11 @@ export async function assignStaffToShift(actor, payload) { } const workforce = await requireWorkforce(client, payload.tenantId, payload.workforceId); + await ensureStaffNotBlockedByBusiness(client, { + tenantId: shift.tenant_id, + businessId: shift.business_id, + staffId: workforce.staff_id, + }); let application = null; if (payload.applicationId) { application = await requireApplication(client, payload.tenantId, payload.applicationId); diff --git a/backend/command-api/src/services/mobile-command-service.js b/backend/command-api/src/services/mobile-command-service.js index 7656233c..c917f245 100644 --- a/backend/command-api/src/services/mobile-command-service.js +++ b/backend/command-api/src/services/mobile-command-service.js @@ -37,6 +37,30 @@ function ensureArray(value) { return Array.isArray(value) ? value : []; } +async function ensureStaffNotBlockedByBusiness(client, { tenantId, businessId, staffId }) { + const blocked = await client.query( + ` + SELECT id, reason, issue_flags + FROM staff_blocks + WHERE tenant_id = $1 + AND business_id = $2 + AND staff_id = $3 + LIMIT 1 + `, + [tenantId, businessId, staffId] + ); + + if (blocked.rowCount > 0) { + throw new AppError('STAFF_BLOCKED', 'Staff is blocked from future shift assignments for this business', 409, { + businessId, + staffId, + blockId: blocked.rows[0].id, + reason: blocked.rows[0].reason || null, + issueFlags: ensureArray(blocked.rows[0].issue_flags || []), + }); + } +} + function buildAssignmentReferencePayload(assignment) { return { assignmentId: assignment.id, @@ -1342,6 +1366,149 @@ export async function assignHubManager(actor, payload) { }); } +export async function createShiftManager(actor, payload) { + const context = await requireClientContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + + const invitedEmail = payload.email.trim().toLowerCase(); + const fullName = `${payload.firstName} ${payload.lastName}`.trim(); + + const userLookup = await client.query( + ` + SELECT id + FROM users + WHERE LOWER(email) = $1 + LIMIT 1 + `, + [invitedEmail] + ); + + const existingMembership = await client.query( + ` + SELECT id, user_id, membership_status, metadata + FROM business_memberships + WHERE tenant_id = $1 + AND business_id = $2 + AND ( + LOWER(invited_email) = $3 + OR ($4::text IS NOT NULL AND user_id = $4) + ) + LIMIT 1 + FOR UPDATE + `, + [context.tenant.tenantId, context.business.businessId, invitedEmail, userLookup.rows[0]?.id || null] + ); + + const membershipMetadata = { + ...(existingMembership.rows[0]?.metadata || {}), + firstName: payload.firstName, + lastName: payload.lastName, + fullName, + phone: normalizePhone(payload.phone), + source: 'mobile-api', + createdBy: actor.uid, + ...(payload.metadata || {}), + }; + + let businessMembershipId; + let membershipStatus; + if (existingMembership.rowCount > 0) { + const result = await client.query( + ` + UPDATE business_memberships + SET user_id = COALESCE(user_id, $2), + invited_email = $3, + membership_status = CASE + WHEN COALESCE(user_id, $2) IS NOT NULL THEN 'ACTIVE' + ELSE membership_status + END, + business_role = $4, + metadata = COALESCE(metadata, '{}'::jsonb) || $5::jsonb, + updated_at = NOW() + WHERE id = $1 + RETURNING id, membership_status + `, + [ + existingMembership.rows[0].id, + userLookup.rows[0]?.id || null, + invitedEmail, + payload.role || 'manager', + JSON.stringify(membershipMetadata), + ] + ); + businessMembershipId = result.rows[0].id; + membershipStatus = result.rows[0].membership_status; + } else { + const result = await client.query( + ` + INSERT INTO business_memberships ( + tenant_id, + business_id, + user_id, + invited_email, + membership_status, + business_role, + metadata + ) + VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb) + RETURNING id, membership_status + `, + [ + context.tenant.tenantId, + context.business.businessId, + userLookup.rows[0]?.id || null, + invitedEmail, + userLookup.rows[0]?.id ? 'ACTIVE' : 'INVITED', + payload.role || 'manager', + JSON.stringify(membershipMetadata), + ] + ); + businessMembershipId = result.rows[0].id; + membershipStatus = result.rows[0].membership_status; + } + + let managerAssignmentId = null; + if (payload.hubId) { + const hub = await requireClockPoint(client, context.tenant.tenantId, context.business.businessId, payload.hubId, { forUpdate: true }); + const assigned = 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] + ); + managerAssignmentId = assigned.rows[0].id; + } + + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'business_membership', + aggregateId: businessMembershipId, + eventType: 'SHIFT_MANAGER_CREATED', + actorUserId: actor.uid, + payload: { + invitedEmail, + fullName, + hubId: payload.hubId || null, + membershipStatus, + }, + }); + + return { + businessMembershipId, + membershipStatus, + invitedEmail, + fullName, + role: payload.role || 'manager', + managerAssignmentId, + }; + }); +} + export async function approveInvoice(actor, payload) { const context = await requireClientContext(actor.uid); return withTransaction(async (client) => { @@ -1483,6 +1650,52 @@ export async function rateWorkerFromCoverage(actor, payload) { ); } + if (payload.markAsBlocked === true) { + await client.query( + ` + INSERT INTO staff_blocks ( + tenant_id, + business_id, + staff_id, + created_by_user_id, + reason, + issue_flags, + metadata + ) + VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb) + ON CONFLICT (business_id, staff_id) DO UPDATE + SET reason = EXCLUDED.reason, + issue_flags = EXCLUDED.issue_flags, + metadata = COALESCE(staff_blocks.metadata, '{}'::jsonb) || EXCLUDED.metadata, + updated_at = NOW() + `, + [ + context.tenant.tenantId, + context.business.businessId, + payload.staffId, + actor.uid, + payload.feedback || null, + JSON.stringify(ensureArray(payload.issueFlags || [])), + JSON.stringify({ + blockedByCoverageReview: true, + assignmentId: assignment.id, + }), + ] + ); + } + + if (payload.markAsBlocked === false) { + await client.query( + ` + DELETE FROM staff_blocks + 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 @@ -1517,6 +1730,7 @@ export async function rateWorkerFromCoverage(actor, payload) { staffId: payload.staffId, rating: payload.rating, markAsFavorite: payload.markAsFavorite ?? null, + markAsBlocked: payload.markAsBlocked ?? null, issueFlags: ensureArray(payload.issueFlags || []), feedback: payload.feedback || null, }; @@ -2285,6 +2499,11 @@ export async function applyForShift(actor, payload) { 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); + await ensureStaffNotBlockedByBusiness(client, { + tenantId: context.tenant.tenantId, + businessId: shiftRole.business_id, + staffId: staff.id, + }); const existingAssignment = await client.query( ` SELECT id diff --git a/backend/command-api/test/mobile-routes.test.js b/backend/command-api/test/mobile-routes.test.js index 13e49964..6bec7af4 100644 --- a/backend/command-api/test/mobile-routes.test.js +++ b/backend/command-api/test/mobile-routes.test.js @@ -44,6 +44,13 @@ function createMobileHandlers() { name: payload.name, costCenterId: payload.costCenterId, }), + createShiftManager: async (_actor, payload) => ({ + businessMembershipId: 'membership-1', + membershipStatus: 'INVITED', + invitedEmail: payload.email, + fullName: `${payload.firstName} ${payload.lastName}`, + managerAssignmentId: payload.hubId ? 'hub-manager-1' : null, + }), approveInvoice: async (_actor, payload) => ({ invoiceId: payload.invoiceId, status: 'APPROVED', @@ -167,6 +174,25 @@ test('POST /commands/client/hubs returns injected hub response', async () => { assert.equal(res.body.name, 'Google North Hub'); }); +test('POST /commands/client/shift-managers creates an invited manager profile', async () => { + const app = createApp({ mobileCommandHandlers: createMobileHandlers() }); + const res = await request(app) + .post('/commands/client/shift-managers') + .set('Authorization', 'Bearer test-token') + .set('Idempotency-Key', 'shift-manager-create-1') + .send({ + hubId: '11111111-1111-4111-8111-111111111111', + email: 'manager@example.com', + firstName: 'Shift', + lastName: 'Lead', + }); + + assert.equal(res.status, 200); + assert.equal(res.body.membershipStatus, 'INVITED'); + assert.equal(res.body.invitedEmail, 'manager@example.com'); + assert.equal(res.body.managerAssignmentId, 'hub-manager-1'); +}); + test('POST /commands/client/billing/invoices/:invoiceId/approve injects invoice id from params', async () => { const app = createApp({ mobileCommandHandlers: createMobileHandlers() }); const res = await request(app) diff --git a/backend/query-api/src/routes/mobile.js b/backend/query-api/src/routes/mobile.js index 402dc345..f39d3a30 100644 --- a/backend/query-api/src/routes/mobile.js +++ b/backend/query-api/src/routes/mobile.js @@ -28,6 +28,7 @@ import { listAssignedShifts, listBusinessAccounts, listBusinessTeamMembers, + listBlockedStaff, listCancelledShifts, listCertificates, listCostCenters, @@ -53,6 +54,7 @@ import { listStaffAvailability, listStaffBankAccounts, listStaffBenefits, + listStaffBenefitHistory, listTodayShifts, listVendorRoles, listVendors, @@ -91,6 +93,7 @@ const defaultQueryService = { listAssignedShifts, listBusinessAccounts, listBusinessTeamMembers, + listBlockedStaff, listCancelledShifts, listCertificates, listCostCenters, @@ -116,6 +119,7 @@ const defaultQueryService = { listStaffAvailability, listStaffBankAccounts, listStaffBenefits, + listStaffBenefitHistory, listTodayShifts, listVendorRoles, listVendors, @@ -253,6 +257,15 @@ export function createMobileQueryRouter(queryService = defaultQueryService) { } }); + router.get('/client/coverage/blocked-staff', requireAuth, requirePolicy('coverage.read', 'coverage'), async (req, res, next) => { + try { + const items = await queryService.listBlockedStaff(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); @@ -622,6 +635,15 @@ export function createMobileQueryRouter(queryService = defaultQueryService) { } }); + router.get('/staff/profile/benefits/history', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => { + try { + const items = await queryService.listStaffBenefitHistory(req.actor.uid, req.query); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + 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); diff --git a/backend/query-api/src/services/mobile-query-service.js b/backend/query-api/src/services/mobile-query-service.js index ba0bf807..420f00f4 100644 --- a/backend/query-api/src/services/mobile-query-service.js +++ b/backend/query-api/src/services/mobile-query-service.js @@ -52,6 +52,13 @@ function metadataBoolean(metadata, key, fallback = false) { return fallback; } +function membershipDisplayName(row) { + const firstName = row?.firstName || row?.metadata?.firstName || null; + const lastName = row?.lastName || row?.metadata?.lastName || null; + const fullName = [firstName, lastName].filter(Boolean).join(' ').trim(); + return fullName || row?.name || row?.displayName || row?.email || row?.invitedEmail || null; +} + function getProfileCompletionFromMetadata(staffRow) { const metadata = staffRow?.metadata || {}; const [firstName, ...lastParts] = (staffRow?.fullName || '').trim().split(/\s+/); @@ -513,10 +520,20 @@ export async function listHubManagers(actorUid, hubId) { hm.id AS "managerAssignmentId", bm.id AS "businessMembershipId", u.id AS "managerId", - COALESCE(u.display_name, u.email) AS name + u.display_name AS "displayName", + u.email, + bm.invited_email AS "invitedEmail", + bm.membership_status AS "membershipStatus", + bm.metadata, + COALESCE( + NULLIF(TRIM(CONCAT_WS(' ', bm.metadata->>'firstName', bm.metadata->>'lastName')), ''), + u.display_name, + u.email, + bm.invited_email + ) AS name FROM hub_managers hm JOIN business_memberships bm ON bm.id = hm.business_membership_id - JOIN users u ON u.id = bm.user_id + LEFT JOIN users u ON u.id = bm.user_id WHERE hm.tenant_id = $1 AND hm.hub_id = $2 ORDER BY name ASC @@ -1323,6 +1340,35 @@ export async function listStaffBenefits(actorUid) { return result.rows; } +export async function listStaffBenefitHistory(actorUid, { limit, offset } = {}) { + const context = await requireStaffContext(actorUid); + const safeLimit = parseLimit(limit, 20, 100); + const safeOffset = Number.isFinite(Number(offset)) && Number(offset) >= 0 ? Number(offset) : 0; + const result = await query( + ` + SELECT + id AS "historyId", + benefit_id AS "benefitId", + benefit_type AS "benefitType", + title, + status, + effective_at AS "effectiveAt", + ended_at AS "endedAt", + tracked_hours AS "trackedHours", + target_hours AS "targetHours", + notes, + metadata + FROM staff_benefit_history + WHERE tenant_id = $1 + AND staff_id = $2 + ORDER BY effective_at DESC, created_at DESC + LIMIT $3 OFFSET $4 + `, + [context.tenant.tenantId, context.staff.staffId, safeLimit, safeOffset] + ); + return result.rows; +} + export async function listCoreTeam(actorUid) { const context = await requireClientContext(actorUid); const result = await query( @@ -1345,6 +1391,28 @@ export async function listCoreTeam(actorUid) { return result.rows; } +export async function listBlockedStaff(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", + sb.reason, + sb.issue_flags AS "issueFlags", + sb.created_at AS "blockedAt" + FROM staff_blocks sb + JOIN staffs st ON st.id = sb.staff_id + WHERE sb.tenant_id = $1 + AND sb.business_id = $2 + ORDER BY sb.created_at 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( @@ -1405,19 +1473,29 @@ export async function listBusinessTeamMembers(actorUid) { SELECT bm.id AS "businessMembershipId", u.id AS "userId", - COALESCE(u.display_name, u.email) AS name, + u.display_name AS "displayName", u.email, - bm.business_role AS role + bm.invited_email AS "invitedEmail", + bm.business_role AS role, + bm.membership_status AS "membershipStatus", + bm.metadata FROM business_memberships bm - JOIN users u ON u.id = bm.user_id + LEFT 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 + AND bm.membership_status IN ('ACTIVE', 'INVITED') + ORDER BY COALESCE(u.display_name, u.email, bm.invited_email) ASC `, [context.tenant.tenantId, context.business.businessId] ); - return result.rows; + return result.rows.map((row) => ({ + businessMembershipId: row.businessMembershipId, + userId: row.userId, + name: membershipDisplayName(row), + email: row.email || row.invitedEmail || null, + role: row.role, + membershipStatus: row.membershipStatus, + })); } export async function getReportSummary(actorUid, { startDate, endDate }) { diff --git a/backend/unified-api/scripts/live-smoke-v2-unified.mjs b/backend/unified-api/scripts/live-smoke-v2-unified.mjs index 84374327..844514f4 100644 --- a/backend/unified-api/scripts/live-smoke-v2-unified.mjs +++ b/backend/unified-api/scripts/live-smoke-v2-unified.mjs @@ -37,6 +37,7 @@ async function apiCall(path, { idempotencyKey, body, expectedStatus = 200, + allowFailure = false, } = {}) { const headers = {}; if (token) headers.Authorization = `Bearer ${token}`; @@ -49,6 +50,12 @@ async function apiCall(path, { body: body === undefined ? undefined : JSON.stringify(body), }); const payload = await readJson(response); + if (allowFailure) { + return { + statusCode: response.status, + body: payload, + }; + } if (response.status !== expectedStatus) { throw new Error(`${method} ${path} expected ${expectedStatus}, got ${response.status}: ${JSON.stringify(payload)}`); } @@ -381,6 +388,29 @@ async function main() { assert.ok(Array.isArray(teamMembers.items)); logStep('client.team-members.ok', { count: teamMembers.items.length }); + const createdShiftManager = await apiCall('/client/shift-managers', { + method: 'POST', + token: ownerSession.sessionToken, + idempotencyKey: uniqueKey('create-shift-manager'), + body: { + hubId: fixture.clockPoint.id, + email: `smoke.manager.${Date.now()}@krowd.com`, + firstName: 'Smoke', + lastName: 'Manager', + phone: '+15550009999', + }, + }); + assert.ok(createdShiftManager.businessMembershipId); + assert.equal(createdShiftManager.membershipStatus, 'INVITED'); + assert.ok(createdShiftManager.managerAssignmentId); + logStep('client.shift-manager.create.ok', createdShiftManager); + + const teamMembersAfterCreate = await apiCall('/client/team-members', { + token: ownerSession.sessionToken, + }); + assert.ok(teamMembersAfterCreate.items.some((item) => item.businessMembershipId === createdShiftManager.businessMembershipId)); + logStep('client.team-members.after-create.ok', { count: teamMembersAfterCreate.items.length }); + const viewedOrders = await apiCall(`/client/orders/view?${reportWindow}`, { token: ownerSession.sessionToken, }); @@ -754,7 +784,9 @@ async function main() { }); const openShift = openShifts.items.find((shift) => shift.shiftId === fixture.shifts.available.id) || openShifts.items[0]; + const blockedApplyCandidate = openShifts.items.find((shift) => shift.shiftId !== openShift.shiftId); assert.ok(openShift); + assert.ok(blockedApplyCandidate); logStep('staff.shifts.open.ok', { count: openShifts.items.length }); const pendingShifts = await apiCall('/staff/shifts/pending', { @@ -858,6 +890,13 @@ async function main() { assert.ok(Array.isArray(benefits.items)); logStep('staff.profile.benefits.ok', { count: benefits.items.length }); + const benefitHistory = await apiCall('/staff/profile/benefits/history?limit=10', { + token: staffAuth.idToken, + }); + assert.ok(Array.isArray(benefitHistory.items)); + assert.ok(benefitHistory.items.length >= 1); + logStep('staff.profile.benefits.history.ok', { count: benefitHistory.items.length }); + const timeCard = await apiCall(`/staff/profile/time-card?month=${new Date().getUTCMonth() + 1}&year=${new Date().getUTCFullYear()}`, { token: staffAuth.idToken, }); @@ -1168,6 +1207,59 @@ async function main() { }); logStep('staff.shifts.request-swap.ok', requestedSwap); + const blockedReview = await apiCall('/client/coverage/reviews', { + method: 'POST', + token: ownerSession.sessionToken, + idempotencyKey: uniqueKey('coverage-block'), + body: { + staffId: fixture.staff.ana.id, + assignmentId: fixture.assignments.completedAna.id, + rating: 2, + markAsBlocked: true, + markAsFavorite: false, + issueFlags: ['LATE_CLOCK_IN'], + feedback: 'Smoke blocked staff test', + }, + }); + assert.equal(blockedReview.markAsBlocked, true); + logStep('client.coverage.block.ok', blockedReview); + + const blockedStaff = await apiCall('/client/coverage/blocked-staff', { + token: ownerSession.sessionToken, + }); + assert.ok(blockedStaff.items.some((item) => item.staffId === fixture.staff.ana.id)); + logStep('client.coverage.blocked-staff.ok', { count: blockedStaff.items.length }); + + const blockedApplyAttempt = await apiCall(`/staff/shifts/${blockedApplyCandidate.shiftId}/apply`, { + method: 'POST', + token: staffAuth.idToken, + idempotencyKey: uniqueKey('staff-shift-apply-blocked'), + body: { + roleId: blockedApplyCandidate.roleId, + }, + allowFailure: true, + }); + assert.equal(blockedApplyAttempt.statusCode, 409); + assert.equal(blockedApplyAttempt.body?.code, 'STAFF_BLOCKED'); + logStep('staff.shifts.apply-blocked.ok', blockedApplyAttempt.body); + + const unblockedReview = await apiCall('/client/coverage/reviews', { + method: 'POST', + token: ownerSession.sessionToken, + idempotencyKey: uniqueKey('coverage-unblock'), + body: { + staffId: fixture.staff.ana.id, + assignmentId: fixture.assignments.completedAna.id, + rating: 5, + markAsBlocked: false, + markAsFavorite: true, + issueFlags: [], + feedback: 'Smoke unblock cleanup', + }, + }); + assert.equal(unblockedReview.markAsBlocked, false); + logStep('client.coverage.unblock.ok', unblockedReview); + const uploadedProfilePhoto = await uploadFile('/staff/profile/photo', staffAuth.idToken, { filename: 'profile-photo.jpg', contentType: 'image/jpeg', diff --git a/docs/BACKEND/API_GUIDES/V2/README.md b/docs/BACKEND/API_GUIDES/V2/README.md index 893f64c4..fb71b760 100644 --- a/docs/BACKEND/API_GUIDES/V2/README.md +++ b/docs/BACKEND/API_GUIDES/V2/README.md @@ -22,9 +22,11 @@ What was validated live against the deployed stack: - staff auth bootstrap - client dashboard, billing, coverage, hubs, vendors, managers, team members, orders, and reports - client coverage incident feed for geofence and override review +- client blocked-staff review and invited shift-manager creation - client hub, order, coverage review, device token, and late-worker cancellation flows - client invoice approve and dispute - staff dashboard, availability, payments, shifts, profile sections, documents, certificates, attire, bank accounts, benefits, and time card +- staff benefit history read model - staff availability, profile, tax form, bank account, shift apply, shift accept, push token registration, clock-in, clock-out, location stream upload, and swap request - direct file upload helpers and verification job creation through the unified host - client and staff sign-out @@ -143,6 +145,7 @@ Those routes still exist for backend/internal compatibility, but mobile/frontend - [Authentication](./authentication.md) - [Unified API](./unified-api.md) +- [Mobile Frontend Implementation Spec](./mobile-frontend-implementation-spec.md) - [Staff Shifts](./staff-shifts.md) - [Core API](./core-api.md) - [Command API](./command-api.md) diff --git a/docs/BACKEND/API_GUIDES/V2/mobile-frontend-implementation-spec.md b/docs/BACKEND/API_GUIDES/V2/mobile-frontend-implementation-spec.md new file mode 100644 index 00000000..e4610a62 --- /dev/null +++ b/docs/BACKEND/API_GUIDES/V2/mobile-frontend-implementation-spec.md @@ -0,0 +1,285 @@ +# Mobile Frontend Implementation Spec + +This is the shortest path for frontend to implement the v2 mobile clients against the unified backend. + +Base URL: + +- `https://krow-api-v2-933560802882.us-central1.run.app` + +Use this doc together with: + +- [Authentication](./authentication.md) +- [Unified API](./unified-api.md) +- [Staff Shifts](./staff-shifts.md) + +## 1) Global rules + +- Use unified routes only. +- Send `Authorization: Bearer ` on protected routes. +- Send `Idempotency-Key` on all write routes. +- Do not call `/query/*`, `/commands/*`, or `/core/*` directly from frontend. + +## 2) Core model frontend should assume + +- `order` is the client-facing request for staffing. +- `shift` is the concrete scheduled unit of work under an order. +- `shiftRole` is the role slot inside a shift that staff apply to. +- `assignment` is the worker-to-shift record once a worker is attached. + +Important consequences: + +- `GET /staff/shifts/open` returns open shift-role opportunities. +- `POST /staff/shifts/:shiftId/apply` must send the `roleId` from that response. +- `GET /client/orders/view` is the timeline/read model for the client app. +- `POST /client/orders/:orderId/edit` and `POST /client/orders/:orderId/cancel` only affect future shifts. + +## 3) Auth implementation + +### Client app + +- sign in with `POST /auth/client/sign-in` +- sign up with `POST /auth/client/sign-up` +- hydrate session with `GET /auth/session` +- sign out with `POST /auth/client/sign-out` + +### Staff app + +- start phone auth with `POST /auth/staff/phone/start` +- complete phone auth with `POST /auth/staff/phone/verify` +- hydrate session with `GET /auth/session` +- sign out with `POST /auth/staff/sign-out` + +Token refresh: + +- keep using Firebase client SDK refresh behavior +- there is no backend `/auth/refresh` route + +## 4) Client app screen mapping + +### Home / dashboard + +- `GET /client/session` +- `GET /client/dashboard` +- `GET /client/reorders` + +### Billing / payments + +- `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` +- `POST /client/billing/invoices/:invoiceId/approve` +- `POST /client/billing/invoices/:invoiceId/dispute` + +### Coverage + +- `GET /client/coverage?date=YYYY-MM-DD` +- `GET /client/coverage/stats?date=YYYY-MM-DD` +- `GET /client/coverage/core-team?date=YYYY-MM-DD` +- `GET /client/coverage/incidents?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD` +- `GET /client/coverage/blocked-staff` +- `POST /client/coverage/reviews` +- `POST /client/coverage/late-workers/:assignmentId/cancel` + +Use `POST /client/coverage/reviews` when the business is rating a worker after coverage review. + +Payload may include: + +```json +{ + "assignmentId": "uuid", + "rating": 4, + "comment": "Strong performance on the shift", + "markAsBlocked": false +} +``` + +If `markAsBlocked` is `true`, backend blocks that worker for that business and rejects future apply or assign attempts until a later review sets `markAsBlocked: false`. + +### Orders + +- `GET /client/orders/view` +- `GET /client/orders/:orderId/reorder-preview` +- `POST /client/orders/one-time` +- `POST /client/orders/recurring` +- `POST /client/orders/permanent` +- `POST /client/orders/:orderId/edit` +- `POST /client/orders/:orderId/cancel` + +Rapid-order flow: + +- use `POST /rapid-orders/process` for the single-call transcribe-and-parse flow + +### Hubs and managers + +- `GET /client/hubs` +- `GET /client/cost-centers` +- `GET /client/hubs/:hubId/managers` +- `GET /client/team-members` +- `POST /client/shift-managers` +- `POST /client/hubs` +- `PUT /client/hubs/:hubId` +- `DELETE /client/hubs/:hubId` +- `POST /client/hubs/:hubId/assign-nfc` +- `POST /client/hubs/:hubId/managers` + +`POST /client/shift-managers` is the fastest path to create an invited manager identity for a business. If `hubId` is provided, backend also links that manager to the hub. + +### Reports + +- `GET /client/reports/summary?date=YYYY-MM-DD` +- `GET /client/reports/daily-ops?date=YYYY-MM-DD` +- `GET /client/reports/spend?date=YYYY-MM-DD` +- `GET /client/reports/coverage?date=YYYY-MM-DD` +- `GET /client/reports/forecast?date=YYYY-MM-DD` +- `GET /client/reports/performance?date=YYYY-MM-DD` +- `GET /client/reports/no-show?date=YYYY-MM-DD` + +## 5) Staff app screen mapping + +### Home / dashboard + +- `GET /staff/session` +- `GET /staff/dashboard` +- `GET /staff/profile-completion` + +### Availability + +- `GET /staff/availability` +- `PUT /staff/availability` +- `POST /staff/availability/quick-set` + +### Find shifts + +- `GET /staff/shifts/open` +- `POST /staff/shifts/:shiftId/apply` + +Rule: + +- send the `roleId` from the open-shifts response +- this is the concrete `shift_roles.id` + +### My shifts + +- `GET /staff/shifts/pending` +- `GET /staff/shifts/assigned` +- `GET /staff/shifts/cancelled` +- `GET /staff/shifts/completed` +- `GET /staff/shifts/:shiftId` +- `POST /staff/shifts/:shiftId/accept` +- `POST /staff/shifts/:shiftId/decline` +- `POST /staff/shifts/:shiftId/request-swap` +- `POST /staff/shifts/:shiftId/submit-for-approval` + +Current swap behavior: + +- backend records the swap request +- assignment moves to `SWAP_REQUESTED` +- shift becomes visible in the replacement pool + +Current limitation: + +- full manager-side swap resolution lifecycle is not yet a separate frontend contract + +### Clock in / clock out + +- `GET /staff/clock-in/shifts/today` +- `GET /staff/clock-in/status` +- `POST /staff/clock-in` +- `POST /staff/clock-out` +- `POST /staff/location-streams` + +Frontend should respect: + +- `clockInMode` +- `allowClockInOverride` +- `latitude` +- `longitude` +- `geofenceRadiusMeters` +- `nfcTagId` + +Clock-in proof rules: + +- use `nfcTagId` for NFC clocking +- use `latitude`, `longitude`, and `accuracyMeters` for geolocation clocking +- send `overrideReason` only when a geofence override is allowed +- send `proofNonce` and `proofTimestamp` on attendance writes + +### Payments + +- `GET /staff/payments/summary` +- `GET /staff/payments/history` +- `GET /staff/payments/chart` + +### Profile + +- `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/benefits/history` +- `GET /staff/profile/time-card` +- `GET /staff/profile/privacy` +- `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` + +Document model rule: + +- `GET /staff/profile/documents` returns only documents +- `GET /staff/profile/attire` returns attire items +- `GET /staff/profile/tax-forms` returns tax-form rows +- `GET /staff/profile/certificates` returns certificates + +### FAQ + +- `GET /staff/faqs` +- `GET /staff/faqs/search?q=...` + +## 6) Upload implementation + +For documents, attire, and certificates: + +1. `POST /upload-file` +2. `POST /create-signed-url` +3. upload file bytes to storage with the signed URL +4. `POST /verifications` +5. finalize with: + - `PUT /staff/profile/documents/:documentId/upload` + - `PUT /staff/profile/attire/:documentId/upload` + - `POST /staff/profile/certificates` + +Use the verification-linked file as the source of truth. + +## 7) What frontend should not assume + +- do not assume order edit mutates past shifts +- do not assume swap resolution is complete beyond the request step +- do not assume raw `/query/*` or `/commands/*` routes are stable for app integration +- do not assume blocked workers can still apply to future shifts for that business + +## 8) Demo reset + +To reset dev demo data: + +```bash +source ~/.nvm/nvm.sh +nvm use 23.5.0 +cd backend/command-api +npm run seed:v2-demo +``` diff --git a/docs/BACKEND/API_GUIDES/V2/unified-api.md b/docs/BACKEND/API_GUIDES/V2/unified-api.md index 9fb50f74..80d3efc1 100644 --- a/docs/BACKEND/API_GUIDES/V2/unified-api.md +++ b/docs/BACKEND/API_GUIDES/V2/unified-api.md @@ -44,6 +44,7 @@ Full auth behavior, including staff phone flow and refresh rules, is documented - `GET /client/coverage/stats` - `GET /client/coverage/core-team` - `GET /client/coverage/incidents` +- `GET /client/coverage/blocked-staff` - `GET /client/hubs` - `GET /client/cost-centers` - `GET /client/vendors` @@ -69,6 +70,7 @@ Full auth behavior, including staff phone flow and refresh rules, is documented - `POST /client/orders/permanent` - `POST /client/orders/:orderId/edit` - `POST /client/orders/:orderId/cancel` +- `POST /client/shift-managers` - `POST /client/hubs` - `PUT /client/hubs/:hubId` - `DELETE /client/hubs/:hubId` @@ -79,6 +81,33 @@ Full auth behavior, including staff phone flow and refresh rules, is documented - `POST /client/coverage/reviews` - `POST /client/coverage/late-workers/:assignmentId/cancel` +Coverage-review request payload may also send: + +```json +{ + "assignmentId": "uuid", + "rating": 2, + "comment": "Worker left the shift early without approval", + "markAsBlocked": true +} +``` + +If `markAsBlocked` is `true`, backend adds that staff member to the business-level blocked list and future apply or assign attempts are rejected until a later review sends `markAsBlocked: false`. + +Shift-manager creation example: + +```json +{ + "firstName": "Nora", + "lastName": "Lead", + "email": "nora.lead@example.com", + "phone": "+15550001234", + "hubId": "uuid" +} +``` + +The manager is created as an invited business membership. If `hubId` is present, backend also links the manager to that hub. + ## 3) Staff routes ### Staff reads @@ -109,6 +138,7 @@ Full auth behavior, including staff phone flow and refresh rules, is documented - `GET /staff/profile/certificates` - `GET /staff/profile/bank-accounts` - `GET /staff/profile/benefits` +- `GET /staff/profile/benefits/history` - `GET /staff/profile/time-card` - `GET /staff/profile/privacy` - `GET /staff/faqs` @@ -197,6 +227,7 @@ These are exposed as direct unified aliases even though they are backed by `core - `accountType` on `POST /staff/profile/bank-accounts` accepts either lowercase or uppercase and is normalized by the backend. - Document routes now return only document rows. They do not mix in attire items anymore. - Tax-form data should come from `GET /staff/profile/tax-forms`, not `GET /staff/profile/documents`. +- Staff benefit activity should come from `GET /staff/profile/benefits/history`; the summary card should keep using `GET /staff/profile/benefits`. - File upload routes return a storage path plus a signed URL. Frontend uploads the file directly to storage using that URL. - The frontend upload contract for documents, attire, and certificates is: 1. `POST /upload-file` @@ -224,6 +255,7 @@ These are exposed as direct unified aliases even though they are backed by `core - send `overrideReason` only when the worker is bypassing a geofence failure and the shift/hub allows overrides - `POST /staff/location-streams` is for the background tracking loop after a worker is already clocked in. - `GET /client/coverage/incidents` is the review feed for geofence breaches, missing-location batches, and clock-in overrides. +- `GET /client/coverage/blocked-staff` is the review feed for workers currently blocked by that business. - `POST /client/coverage/late-workers/:assignmentId/cancel` is the client-side recovery action when lateness is confirmed by incident evidence or elapsed grace time. - Raw location stream payloads are stored in the private v2 bucket; SQL only stores the summary and incident index. - Push delivery is backed by: