import { AppError } from '../lib/errors.js'; import { FAQ_CATEGORIES } from '../data/faqs.js'; import { query } from './db.js'; import { requireClientContext, requireStaffContext } from './actor-context.js'; function parseLimit(value, fallback = 20, max = 100) { const parsed = Number.parseInt(`${value || fallback}`, 10); if (!Number.isFinite(parsed) || parsed <= 0) return fallback; return Math.min(parsed, max); } function parseDate(value, field) { const date = new Date(value); if (Number.isNaN(date.getTime())) { throw new AppError('VALIDATION_ERROR', `${field} must be a valid ISO date`, 400, { field }); } return date; } function parseDateRange(startDate, endDate, fallbackDays = 7) { const start = startDate ? parseDate(startDate, 'startDate') : new Date(); const end = endDate ? parseDate(endDate, 'endDate') : new Date(start.getTime() + (fallbackDays * 24 * 60 * 60 * 1000)); if (start > end) { throw new AppError('VALIDATION_ERROR', 'startDate must be before endDate', 400); } return { start: start.toISOString(), end: end.toISOString(), }; } function startOfDay(value) { const date = parseDate(value || new Date().toISOString(), 'date'); date.setUTCHours(0, 0, 0, 0); return date; } function endOfDay(value) { const date = startOfDay(value); date.setUTCDate(date.getUTCDate() + 1); return date; } function metadataArray(metadata, key) { const value = metadata?.[key]; return Array.isArray(value) ? value : []; } function metadataBoolean(metadata, key, fallback = false) { const value = metadata?.[key]; if (typeof value === 'boolean') return value; return fallback; } function getProfileCompletionFromMetadata(staffRow) { const metadata = staffRow?.metadata || {}; const [firstName, ...lastParts] = (staffRow?.fullName || '').trim().split(/\s+/); const lastName = lastParts.join(' '); const checks = { firstName: Boolean(metadata.firstName || firstName), lastName: Boolean(metadata.lastName || lastName), email: Boolean(staffRow?.email), phone: Boolean(staffRow?.phone), preferredLocations: metadataArray(metadata, 'preferredLocations').length > 0, skills: metadataArray(metadata, 'skills').length > 0, industries: metadataArray(metadata, 'industries').length > 0, emergencyContact: Boolean(metadata.emergencyContact?.name && metadata.emergencyContact?.phone), }; const missingFields = Object.entries(checks) .filter(([, value]) => !value) .map(([key]) => key); return { completed: missingFields.length === 0, missingFields, fields: checks, }; } export async function getClientSession(actorUid) { const context = await requireClientContext(actorUid); return context; } export async function getStaffSession(actorUid) { const context = await requireStaffContext(actorUid); return context; } export async function getClientDashboard(actorUid) { const context = await requireClientContext(actorUid); const businessId = context.business.businessId; const tenantId = context.tenant.tenantId; const [spendResult, projectionResult, coverageResult, activityResult] = await Promise.all([ query( ` SELECT COALESCE(SUM(total_cents) FILTER (WHERE created_at >= date_trunc('week', NOW())), 0)::BIGINT AS "weeklySpendCents" FROM invoices WHERE tenant_id = $1 AND business_id = $2 `, [tenantId, businessId] ), query( ` SELECT COALESCE(SUM(sr.bill_rate_cents * sr.workers_needed), 0)::BIGINT AS "projectedSpendCents" FROM shifts s JOIN shift_roles sr ON sr.shift_id = s.id WHERE s.tenant_id = $1 AND s.business_id = $2 AND s.starts_at >= NOW() AND s.starts_at < NOW() + INTERVAL '7 days' AND s.status IN ('OPEN', 'PENDING_CONFIRMATION', 'ASSIGNED', 'ACTIVE') `, [tenantId, businessId] ), query( ` SELECT COALESCE(SUM(required_workers), 0)::INTEGER AS "neededWorkersToday", COALESCE(SUM(assigned_workers), 0)::INTEGER AS "filledWorkersToday", COALESCE(SUM(required_workers - assigned_workers), 0)::INTEGER AS "openPositionsToday" FROM shifts WHERE tenant_id = $1 AND business_id = $2 AND starts_at >= date_trunc('day', NOW()) AND starts_at < date_trunc('day', NOW()) + INTERVAL '1 day' `, [tenantId, businessId] ), query( ` SELECT COALESCE(COUNT(*) FILTER (WHERE a.status = 'NO_SHOW'), 0)::INTEGER AS "lateWorkersToday", COALESCE(COUNT(*) FILTER (WHERE a.status IN ('CHECKED_IN', 'CHECKED_OUT', 'COMPLETED')), 0)::INTEGER AS "checkedInWorkersToday", COALESCE(AVG(sr.bill_rate_cents), 0)::NUMERIC(12,2) AS "averageShiftCostCents" FROM shifts s LEFT JOIN assignments a ON a.shift_id = s.id LEFT JOIN shift_roles sr ON sr.shift_id = s.id WHERE s.tenant_id = $1 AND s.business_id = $2 AND s.starts_at >= date_trunc('day', NOW()) AND s.starts_at < date_trunc('day', NOW()) + INTERVAL '1 day' `, [tenantId, businessId] ), ]); return { userName: context.user.displayName || context.user.email, businessName: context.business.businessName, businessId, spending: { weeklySpendCents: Number(spendResult.rows[0]?.weeklySpendCents || 0), projectedNext7DaysCents: Number(projectionResult.rows[0]?.projectedSpendCents || 0), }, coverage: coverageResult.rows[0], liveActivity: activityResult.rows[0], }; } export async function listRecentReorders(actorUid, limit) { const context = await requireClientContext(actorUid); const result = await query( ` SELECT o.id, o.title, o.starts_at AS "date", COALESCE(cp.label, o.location_name) AS "hubName", COALESCE(COUNT(sr.id), 0)::INTEGER AS "positionCount", COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType" FROM orders o LEFT JOIN shifts s ON s.order_id = o.id LEFT JOIN shift_roles sr ON sr.shift_id = s.id LEFT JOIN clock_points cp ON cp.id = s.clock_point_id WHERE o.tenant_id = $1 AND o.business_id = $2 AND o.status IN ('COMPLETED', 'ACTIVE', 'FILLED') GROUP BY o.id, cp.label ORDER BY o.starts_at DESC NULLS LAST LIMIT $3 `, [context.tenant.tenantId, context.business.businessId, parseLimit(limit, 8, 20)] ); return result.rows; } export async function listBusinessAccounts(actorUid) { const context = await requireClientContext(actorUid); const result = await query( ` SELECT id AS "accountId", provider_name AS "bankName", provider_reference AS "providerReference", last4, is_primary AS "isPrimary", COALESCE(metadata->>'accountType', 'CHECKING') AS "accountType", COALESCE(metadata->>'routingNumberMasked', '***') AS "routingNumberMasked" FROM accounts WHERE tenant_id = $1 AND owner_business_id = $2 ORDER BY is_primary DESC, created_at DESC `, [context.tenant.tenantId, context.business.businessId] ); return result.rows; } export async function listPendingInvoices(actorUid) { const context = await requireClientContext(actorUid); const result = await query( ` SELECT i.id AS "invoiceId", i.invoice_number AS "invoiceNumber", i.total_cents AS "amountCents", i.status, i.due_at AS "dueDate", v.id AS "vendorId", v.company_name AS "vendorName" FROM invoices i LEFT JOIN vendors v ON v.id = i.vendor_id WHERE i.tenant_id = $1 AND i.business_id = $2 AND i.status IN ('PENDING', 'PENDING_REVIEW', 'APPROVED', 'OVERDUE', 'DISPUTED') ORDER BY i.due_at ASC NULLS LAST, i.created_at DESC `, [context.tenant.tenantId, context.business.businessId] ); return result.rows; } export async function listInvoiceHistory(actorUid) { const context = await requireClientContext(actorUid); const result = await query( ` SELECT i.id AS "invoiceId", i.invoice_number AS "invoiceNumber", i.total_cents AS "amountCents", i.status, i.updated_at AS "paymentDate", v.id AS "vendorId", v.company_name AS "vendorName" FROM invoices i LEFT JOIN vendors v ON v.id = i.vendor_id WHERE i.tenant_id = $1 AND i.business_id = $2 ORDER BY i.created_at DESC `, [context.tenant.tenantId, context.business.businessId] ); return result.rows; } export async function getCurrentBill(actorUid) { const context = await requireClientContext(actorUid); const result = await query( ` SELECT COALESCE(SUM(total_cents), 0)::BIGINT AS "currentBillCents" FROM invoices WHERE tenant_id = $1 AND business_id = $2 AND status NOT IN ('PAID', 'VOID') AND created_at >= date_trunc('month', NOW()) `, [context.tenant.tenantId, context.business.businessId] ); return result.rows[0]; } export async function getSavings(actorUid) { const context = await requireClientContext(actorUid); const result = await query( ` SELECT COALESCE(SUM(COALESCE(NULLIF(metadata->>'savingsCents', '')::BIGINT, 0)), 0)::BIGINT AS "savingsCents" FROM invoices WHERE tenant_id = $1 AND business_id = $2 `, [context.tenant.tenantId, context.business.businessId] ); return result.rows[0]; } export async function getSpendBreakdown(actorUid, { startDate, endDate }) { const context = await requireClientContext(actorUid); const range = parseDateRange(startDate, endDate, 30); const result = await query( ` WITH items AS ( SELECT COALESCE(sr.role_name, 'Unknown') AS category, SUM(sr.bill_rate_cents * GREATEST(sr.assigned_count, sr.workers_needed))::BIGINT AS amount_cents FROM shifts s JOIN shift_roles sr ON sr.shift_id = s.id WHERE s.tenant_id = $1 AND s.business_id = $2 AND s.starts_at >= $3::timestamptz AND s.starts_at <= $4::timestamptz GROUP BY sr.role_name ) SELECT category, amount_cents AS "amountCents", CASE WHEN SUM(amount_cents) OVER () = 0 THEN 0 ELSE ROUND((amount_cents::numeric / SUM(amount_cents) OVER ()) * 100, 2) END AS percentage FROM items ORDER BY amount_cents DESC, category ASC `, [context.tenant.tenantId, context.business.businessId, range.start, range.end] ); return result.rows; } export async function listCoverageByDate(actorUid, { date }) { const context = await requireClientContext(actorUid); const from = startOfDay(date).toISOString(); const to = endOfDay(date).toISOString(); const result = await query( ` SELECT s.id AS "shiftId", s.title, s.starts_at AS "startsAt", s.ends_at AS "endsAt", s.required_workers AS "requiredWorkers", s.assigned_workers AS "assignedWorkers", sr.role_name AS "roleName", a.id AS "assignmentId", a.status AS "assignmentStatus", st.id AS "staffId", st.full_name AS "staffName", attendance_sessions.check_in_at AS "checkInAt" FROM shifts s LEFT JOIN shift_roles sr ON sr.shift_id = s.id LEFT JOIN assignments a ON a.shift_id = s.id LEFT JOIN staffs st ON st.id = a.staff_id LEFT JOIN attendance_sessions ON attendance_sessions.assignment_id = a.id WHERE s.tenant_id = $1 AND s.business_id = $2 AND s.starts_at >= $3::timestamptz AND s.starts_at < $4::timestamptz ORDER BY s.starts_at ASC, st.full_name ASC NULLS LAST `, [context.tenant.tenantId, context.business.businessId, from, to] ); const grouped = new Map(); for (const row of result.rows) { const current = grouped.get(row.shiftId) || { shiftId: row.shiftId, roleName: row.roleName, timeRange: { startsAt: row.startsAt, endsAt: row.endsAt, }, requiredWorkerCount: row.requiredWorkers, assignedWorkerCount: row.assignedWorkers, assignedWorkers: [], }; if (row.staffId) { current.assignedWorkers.push({ assignmentId: row.assignmentId, staffId: row.staffId, fullName: row.staffName, status: row.assignmentStatus, checkInAt: row.checkInAt, }); } grouped.set(row.shiftId, current); } return Array.from(grouped.values()); } export async function getCoverageStats(actorUid, { date }) { const items = await listCoverageByDate(actorUid, { date }); const totals = items.reduce((acc, item) => { acc.totalPositionsNeeded += Number(item.requiredWorkerCount || 0); acc.totalPositionsConfirmed += Number(item.assignedWorkerCount || 0); acc.totalWorkersCheckedIn += item.assignedWorkers.filter((worker) => worker.checkInAt).length; acc.totalWorkersEnRoute += item.assignedWorkers.filter((worker) => worker.status === 'ACCEPTED').length; acc.totalWorkersLate += item.assignedWorkers.filter((worker) => worker.status === 'NO_SHOW').length; return acc; }, { totalPositionsNeeded: 0, totalPositionsConfirmed: 0, totalWorkersCheckedIn: 0, totalWorkersEnRoute: 0, totalWorkersLate: 0, }); return { ...totals, totalCoveragePercentage: totals.totalPositionsNeeded === 0 ? 0 : Math.round((totals.totalPositionsConfirmed / totals.totalPositionsNeeded) * 100), }; } export async function listHubs(actorUid) { const context = await requireClientContext(actorUid); const result = await query( ` SELECT cp.id AS "hubId", cp.label AS name, cp.address AS "fullAddress", cp.latitude, cp.longitude, cp.nfc_tag_uid AS "nfcTagId", cp.metadata->>'city' AS city, cp.metadata->>'state' AS state, cp.metadata->>'zipCode' AS "zipCode", cc.id AS "costCenterId", cc.name AS "costCenterName" FROM clock_points cp LEFT JOIN cost_centers cc ON cc.id = cp.cost_center_id WHERE cp.tenant_id = $1 AND cp.business_id = $2 AND cp.status = 'ACTIVE' ORDER BY cp.label ASC `, [context.tenant.tenantId, context.business.businessId] ); return result.rows; } export async function listCostCenters(actorUid) { const context = await requireClientContext(actorUid); const result = await query( ` SELECT id AS "costCenterId", name FROM cost_centers WHERE tenant_id = $1 AND business_id = $2 AND status = 'ACTIVE' ORDER BY name ASC `, [context.tenant.tenantId, context.business.businessId] ); return result.rows; } export async function listVendors(actorUid) { const context = await requireClientContext(actorUid); const result = await query( ` SELECT id AS "vendorId", company_name AS "vendorName" FROM vendors WHERE tenant_id = $1 AND status = 'ACTIVE' ORDER BY company_name ASC `, [context.tenant.tenantId] ); return result.rows; } export async function listVendorRoles(actorUid, vendorId) { const context = await requireClientContext(actorUid); const result = await query( ` SELECT rc.id AS "roleId", rc.code AS "roleCode", rc.name AS "roleName", COALESCE(MAX(sr.bill_rate_cents), 0)::INTEGER AS "hourlyRateCents" FROM roles_catalog rc LEFT JOIN shift_roles sr ON sr.role_id = rc.id LEFT JOIN shifts s ON s.id = sr.shift_id AND (s.vendor_id = $2 OR $2::uuid IS NULL) WHERE rc.tenant_id = $1 AND rc.status = 'ACTIVE' GROUP BY rc.id ORDER BY rc.name ASC `, [context.tenant.tenantId, vendorId || null] ); return result.rows; } export async function listHubManagers(actorUid, hubId) { const context = await requireClientContext(actorUid); const result = await query( ` SELECT hm.id AS "managerAssignmentId", bm.id AS "businessMembershipId", u.id AS "managerId", COALESCE(u.display_name, u.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 WHERE hm.tenant_id = $1 AND hm.hub_id = $2 ORDER BY name ASC `, [context.tenant.tenantId, hubId] ); return result.rows; } export async function listOrderItemsByDateRange(actorUid, { startDate, endDate }) { const context = await requireClientContext(actorUid); const range = parseDateRange(startDate, endDate, 14); const result = await query( ` SELECT sr.id AS "itemId", o.id AS "orderId", COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType", sr.role_name AS "roleName", s.starts_at AS date, s.starts_at AS "startsAt", s.ends_at AS "endsAt", sr.workers_needed AS "requiredWorkerCount", sr.assigned_count AS "filledCount", sr.bill_rate_cents AS "hourlyRateCents", (sr.bill_rate_cents * sr.workers_needed)::BIGINT AS "totalCostCents", COALESCE(cp.label, s.location_name) AS "locationName", s.status, COALESCE( json_agg( json_build_object( 'applicationId', a.application_id, 'workerName', st.full_name, 'role', sr.role_name, 'confirmationStatus', a.status ) ) FILTER (WHERE a.id IS NOT NULL), '[]'::json ) AS workers FROM shift_roles sr JOIN shifts s ON s.id = sr.shift_id JOIN orders o ON o.id = s.order_id LEFT JOIN clock_points cp ON cp.id = s.clock_point_id LEFT JOIN assignments a ON a.shift_role_id = sr.id LEFT JOIN staffs st ON st.id = a.staff_id WHERE o.tenant_id = $1 AND o.business_id = $2 AND s.starts_at >= $3::timestamptz AND s.starts_at <= $4::timestamptz GROUP BY sr.id, o.id, s.id, cp.label ORDER BY s.starts_at ASC, sr.role_name ASC `, [context.tenant.tenantId, context.business.businessId, range.start, range.end] ); return result.rows; } export async function getStaffDashboard(actorUid) { const context = await requireStaffContext(actorUid); const [todayShifts, tomorrowShifts, recommendedShifts, benefits] = await Promise.all([ listTodayShifts(actorUid), listAssignedShifts(actorUid, { startDate: endOfDay(new Date().toISOString()).toISOString(), endDate: endOfDay(new Date(Date.now() + (24 * 60 * 60 * 1000)).toISOString()).toISOString(), }), listOpenShifts(actorUid, { limit: 5 }), listStaffBenefits(actorUid), ]); return { staffName: context.staff.fullName, todaysShifts: todayShifts, tomorrowsShifts: tomorrowShifts.slice(0, 5), recommendedShifts: recommendedShifts.slice(0, 5), benefits, }; } export async function getStaffProfileCompletion(actorUid) { const context = await requireStaffContext(actorUid); const completion = getProfileCompletionFromMetadata(context.staff); return { staffId: context.staff.staffId, ...completion, }; } export async function listStaffAvailability(actorUid, { startDate, endDate }) { const context = await requireStaffContext(actorUid); const range = parseDateRange(startDate, endDate, 6); const recurring = await query( ` SELECT day_of_week AS "dayOfWeek", availability_status AS status, time_slots AS slots FROM staff_availability WHERE tenant_id = $1 AND staff_id = $2 ORDER BY day_of_week ASC `, [context.tenant.tenantId, context.staff.staffId] ); const rowsByDay = new Map(recurring.rows.map((row) => [Number(row.dayOfWeek), row])); const items = []; let cursor = new Date(range.start); const end = new Date(range.end); while (cursor <= end) { const day = cursor.getUTCDay(); const recurringEntry = rowsByDay.get(day); items.push({ date: cursor.toISOString().slice(0, 10), dayOfWeek: day, availabilityStatus: recurringEntry?.status || 'UNAVAILABLE', slots: recurringEntry?.slots || [], }); cursor = new Date(cursor.getTime() + (24 * 60 * 60 * 1000)); } return items; } export async function listTodayShifts(actorUid) { const context = await requireStaffContext(actorUid); const from = startOfDay(new Date().toISOString()).toISOString(); const to = endOfDay(new Date().toISOString()).toISOString(); const result = await query( ` SELECT a.id AS "assignmentId", s.id AS "shiftId", sr.role_name AS "roleName", COALESCE(cp.label, s.location_name) AS location, s.starts_at AS "startTime", s.ends_at AS "endTime", COALESCE(attendance_sessions.status, 'NOT_CLOCKED_IN') AS "attendanceStatus", attendance_sessions.check_in_at AS "clockInAt" FROM assignments a JOIN shifts s ON s.id = a.shift_id JOIN shift_roles sr ON sr.id = a.shift_role_id LEFT JOIN clock_points cp ON cp.id = s.clock_point_id LEFT JOIN attendance_sessions ON attendance_sessions.assignment_id = a.id WHERE a.tenant_id = $1 AND a.staff_id = $2 AND s.starts_at >= $3::timestamptz AND s.starts_at < $4::timestamptz AND a.status IN ('ASSIGNED', 'ACCEPTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED') ORDER BY ABS(EXTRACT(EPOCH FROM (s.starts_at - NOW()))) ASC `, [context.tenant.tenantId, context.staff.staffId, from, to] ); return result.rows; } export async function getCurrentAttendanceStatus(actorUid) { const context = await requireStaffContext(actorUid); const result = await query( ` SELECT a.shift_id AS "activeShiftId", attendance_sessions.status AS "attendanceStatus", attendance_sessions.check_in_at AS "clockInAt" FROM attendance_sessions JOIN assignments a ON a.id = attendance_sessions.assignment_id WHERE attendance_sessions.tenant_id = $1 AND attendance_sessions.staff_id = $2 AND attendance_sessions.status = 'OPEN' ORDER BY attendance_sessions.updated_at DESC LIMIT 1 `, [context.tenant.tenantId, context.staff.staffId] ); return result.rows[0] || { attendanceStatus: 'NOT_CLOCKED_IN', activeShiftId: null, clockInAt: null, }; } export async function getPaymentsSummary(actorUid, { startDate, endDate }) { const context = await requireStaffContext(actorUid); const range = parseDateRange(startDate, endDate, 30); const result = await query( ` SELECT COALESCE(SUM(amount_cents), 0)::BIGINT AS "totalEarningsCents" FROM recent_payments WHERE tenant_id = $1 AND staff_id = $2 AND created_at >= $3::timestamptz AND created_at <= $4::timestamptz `, [context.tenant.tenantId, context.staff.staffId, range.start, range.end] ); return result.rows[0]; } export async function listPaymentsHistory(actorUid, { startDate, endDate }) { const context = await requireStaffContext(actorUid); const range = parseDateRange(startDate, endDate, 30); const result = await query( ` SELECT rp.id AS "paymentId", rp.amount_cents AS "amountCents", COALESCE(rp.process_date, rp.created_at) AS date, rp.status, s.title AS "shiftName", COALESCE(cp.label, s.location_name) AS location, sr.pay_rate_cents AS "hourlyRateCents", COALESCE(ts.regular_minutes + ts.overtime_minutes, 0) AS minutesWorked FROM recent_payments rp LEFT JOIN assignments a ON a.id = rp.assignment_id LEFT JOIN shifts s ON s.id = a.shift_id LEFT JOIN shift_roles sr ON sr.id = a.shift_role_id LEFT JOIN timesheets ts ON ts.assignment_id = a.id LEFT JOIN clock_points cp ON cp.id = s.clock_point_id WHERE rp.tenant_id = $1 AND rp.staff_id = $2 AND rp.created_at >= $3::timestamptz AND rp.created_at <= $4::timestamptz ORDER BY date DESC `, [context.tenant.tenantId, context.staff.staffId, range.start, range.end] ); return result.rows; } export async function getPaymentChart(actorUid, { startDate, endDate, bucket = 'day' }) { const context = await requireStaffContext(actorUid); const range = parseDateRange(startDate, endDate, 30); const dateBucket = bucket === 'week' ? 'week' : bucket === 'month' ? 'month' : 'day'; const result = await query( ` SELECT date_trunc('${dateBucket}', COALESCE(process_date, created_at)) AS bucket, COALESCE(SUM(amount_cents), 0)::BIGINT AS "amountCents" FROM recent_payments WHERE tenant_id = $1 AND staff_id = $2 AND created_at >= $3::timestamptz AND created_at <= $4::timestamptz GROUP BY 1 ORDER BY 1 ASC `, [context.tenant.tenantId, context.staff.staffId, range.start, range.end] ); return result.rows; } export async function listAssignedShifts(actorUid, { startDate, endDate }) { const context = await requireStaffContext(actorUid); const range = parseDateRange(startDate, endDate, 14); const result = await query( ` SELECT a.id AS "assignmentId", s.id AS "shiftId", sr.role_name AS "roleName", COALESCE(cp.label, s.location_name) AS location, s.starts_at AS date, s.starts_at AS "startTime", s.ends_at AS "endTime", sr.pay_rate_cents AS "hourlyRateCents", COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType", a.status FROM assignments a JOIN shifts s ON s.id = a.shift_id JOIN shift_roles sr ON sr.id = a.shift_role_id JOIN orders o ON o.id = s.order_id LEFT JOIN clock_points cp ON cp.id = s.clock_point_id WHERE a.tenant_id = $1 AND a.staff_id = $2 AND s.starts_at >= $3::timestamptz AND s.starts_at <= $4::timestamptz AND a.status IN ('ASSIGNED', 'ACCEPTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED') ORDER BY s.starts_at ASC `, [context.tenant.tenantId, context.staff.staffId, range.start, range.end] ); return result.rows; } export async function listOpenShifts(actorUid, { limit, search } = {}) { const context = await requireStaffContext(actorUid); const result = await query( ` WITH open_roles AS ( SELECT s.id AS "shiftId", sr.id AS "roleId", sr.role_name AS "roleName", COALESCE(cp.label, s.location_name) AS location, s.starts_at AS date, s.starts_at AS "startTime", s.ends_at AS "endTime", sr.pay_rate_cents AS "hourlyRateCents", COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType", FALSE AS "instantBook", sr.workers_needed AS "requiredWorkerCount" FROM shifts s JOIN shift_roles sr ON sr.shift_id = s.id JOIN orders o ON o.id = s.order_id LEFT JOIN clock_points cp ON cp.id = s.clock_point_id WHERE s.tenant_id = $1 AND s.status = 'OPEN' AND sr.role_code = $4 AND ($2::text IS NULL OR sr.role_name ILIKE '%' || $2 || '%' OR COALESCE(cp.label, s.location_name) ILIKE '%' || $2 || '%') AND NOT EXISTS ( SELECT 1 FROM applications a WHERE a.shift_role_id = sr.id AND a.staff_id = $3 AND a.status IN ('PENDING', 'CONFIRMED') ) ), swap_roles AS ( SELECT s.id AS "shiftId", sr.id AS "roleId", sr.role_name AS "roleName", COALESCE(cp.label, s.location_name) AS location, s.starts_at AS date, s.starts_at AS "startTime", s.ends_at AS "endTime", sr.pay_rate_cents AS "hourlyRateCents", COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType", FALSE AS "instantBook", 1::INTEGER AS "requiredWorkerCount" FROM assignments a JOIN shifts s ON s.id = a.shift_id JOIN shift_roles sr ON sr.id = a.shift_role_id JOIN orders o ON o.id = s.order_id LEFT JOIN clock_points cp ON cp.id = s.clock_point_id WHERE a.tenant_id = $1 AND a.status = 'SWAP_REQUESTED' AND a.staff_id <> $3 AND sr.role_code = $4 AND ($2::text IS NULL OR sr.role_name ILIKE '%' || $2 || '%' OR COALESCE(cp.label, s.location_name) ILIKE '%' || $2 || '%') AND NOT EXISTS ( SELECT 1 FROM applications app WHERE app.shift_role_id = sr.id AND app.staff_id = $3 AND app.status IN ('PENDING', 'CONFIRMED') ) ) SELECT * FROM ( SELECT * FROM open_roles UNION ALL SELECT * FROM swap_roles ) items ORDER BY "startTime" ASC LIMIT $5 `, [ context.tenant.tenantId, search || null, context.staff.staffId, context.staff.primaryRole || 'BARISTA', parseLimit(limit, 20, 100), ] ); return result.rows; } export async function listPendingAssignments(actorUid) { const context = await requireStaffContext(actorUid); const result = await query( ` SELECT a.id AS "assignmentId", s.id AS "shiftId", s.title, sr.role_name AS "roleName", s.starts_at AS "startTime", s.ends_at AS "endTime", COALESCE(cp.label, s.location_name) AS location, a.created_at AS "responseDeadline" FROM assignments a JOIN shifts s ON s.id = a.shift_id JOIN shift_roles sr ON sr.id = a.shift_role_id LEFT JOIN clock_points cp ON cp.id = s.clock_point_id WHERE a.tenant_id = $1 AND a.staff_id = $2 AND a.status = 'ASSIGNED' ORDER BY s.starts_at ASC `, [context.tenant.tenantId, context.staff.staffId] ); return result.rows; } export async function getStaffShiftDetail(actorUid, shiftId) { const context = await requireStaffContext(actorUid); const result = await query( ` SELECT s.id AS "shiftId", s.title, o.description, COALESCE(cp.label, s.location_name) AS location, s.location_address AS address, s.starts_at AS date, s.starts_at AS "startTime", s.ends_at AS "endTime", sr.id AS "roleId", sr.role_name AS "roleName", sr.pay_rate_cents AS "hourlyRateCents", COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType", sr.workers_needed AS "requiredCount", sr.assigned_count AS "confirmedCount", a.status AS "assignmentStatus", app.status AS "applicationStatus" FROM shifts s JOIN orders o ON o.id = s.order_id JOIN shift_roles sr ON sr.shift_id = s.id LEFT JOIN clock_points cp ON cp.id = s.clock_point_id LEFT JOIN assignments a ON a.shift_role_id = sr.id AND a.staff_id = $3 LEFT JOIN applications app ON app.shift_role_id = sr.id AND app.staff_id = $3 WHERE s.tenant_id = $1 AND s.id = $2 ORDER BY sr.role_name ASC LIMIT 1 `, [context.tenant.tenantId, shiftId, context.staff.staffId] ); if (result.rowCount === 0) { throw new AppError('NOT_FOUND', 'Shift not found', 404, { shiftId }); } return result.rows[0]; } export async function listCancelledShifts(actorUid) { const context = await requireStaffContext(actorUid); const result = await query( ` SELECT a.id AS "assignmentId", s.id AS "shiftId", s.title, COALESCE(cp.label, s.location_name) AS location, s.starts_at AS date, a.metadata->>'cancellationReason' AS "cancellationReason" FROM assignments a JOIN shifts s ON s.id = a.shift_id LEFT JOIN clock_points cp ON cp.id = s.clock_point_id WHERE a.tenant_id = $1 AND a.staff_id = $2 AND a.status = 'CANCELLED' ORDER BY s.starts_at DESC `, [context.tenant.tenantId, context.staff.staffId] ); return result.rows; } export async function listCompletedShifts(actorUid) { const context = await requireStaffContext(actorUid); const result = await query( ` SELECT a.id AS "assignmentId", s.id AS "shiftId", s.title, COALESCE(cp.label, s.location_name) AS location, s.starts_at AS date, COALESCE(ts.regular_minutes + ts.overtime_minutes, 0) AS "minutesWorked", COALESCE(rp.status, 'PENDING') AS "paymentStatus" FROM assignments a JOIN shifts s ON s.id = a.shift_id LEFT JOIN clock_points cp ON cp.id = s.clock_point_id LEFT JOIN timesheets ts ON ts.assignment_id = a.id LEFT JOIN recent_payments rp ON rp.assignment_id = a.id WHERE a.tenant_id = $1 AND a.staff_id = $2 AND a.status IN ('CHECKED_OUT', 'COMPLETED') ORDER BY s.starts_at DESC `, [context.tenant.tenantId, context.staff.staffId] ); return result.rows; } export async function getProfileSectionsStatus(actorUid) { const context = await requireStaffContext(actorUid); const completion = getProfileCompletionFromMetadata(context.staff); const [documents, certificates, benefits] = await Promise.all([ listProfileDocuments(actorUid), listCertificates(actorUid), listStaffBenefits(actorUid), ]); return { personalInfoCompleted: completion.fields.firstName && completion.fields.lastName && completion.fields.email && completion.fields.phone && completion.fields.preferredLocations, emergencyContactCompleted: completion.fields.emergencyContact, experienceCompleted: completion.fields.skills && completion.fields.industries, attireCompleted: documents.filter((item) => item.documentType === 'ATTIRE').every((item) => item.status === 'VERIFIED'), taxFormsCompleted: documents.filter((item) => item.documentType === 'TAX_FORM').every((item) => item.status === 'VERIFIED'), benefitsConfigured: benefits.length > 0, certificateCount: certificates.length, }; } export async function getPersonalInfo(actorUid) { const context = await requireStaffContext(actorUid); const metadata = context.staff.metadata || {}; return { staffId: context.staff.staffId, firstName: metadata.firstName || context.staff.fullName.split(' ')[0] || null, lastName: metadata.lastName || context.staff.fullName.split(' ').slice(1).join(' ') || null, bio: metadata.bio || null, preferredLocations: metadataArray(metadata, 'preferredLocations'), maxDistanceMiles: metadata.maxDistanceMiles || null, industries: metadataArray(metadata, 'industries'), skills: metadataArray(metadata, 'skills'), email: context.staff.email, phone: context.staff.phone, }; } export async function listIndustries(actorUid) { const context = await requireStaffContext(actorUid); return metadataArray(context.staff.metadata || {}, 'industries'); } export async function listSkills(actorUid) { const context = await requireStaffContext(actorUid); return metadataArray(context.staff.metadata || {}, 'skills'); } export async function listProfileDocuments(actorUid) { const context = await requireStaffContext(actorUid); const result = await query( ` SELECT d.id AS "documentId", d.document_type AS "documentType", d.name, sd.id AS "staffDocumentId", sd.file_uri AS "fileUri", COALESCE(sd.status, 'NOT_UPLOADED') AS status, sd.expires_at AS "expiresAt", sd.metadata FROM documents d LEFT JOIN staff_documents sd ON sd.document_id = d.id AND sd.tenant_id = d.tenant_id AND sd.staff_id = $2 WHERE d.tenant_id = $1 AND d.document_type IN ('DOCUMENT', 'GOVERNMENT_ID', 'ATTIRE', 'TAX_FORM') ORDER BY d.name ASC `, [context.tenant.tenantId, context.staff.staffId] ); return result.rows; } export async function listCertificates(actorUid) { const context = await requireStaffContext(actorUid); const result = await query( ` SELECT id AS "certificateId", certificate_type AS "certificateType", COALESCE(metadata->>'name', certificate_type) AS name, file_uri AS "fileUri", metadata->>'issuer' AS issuer, certificate_number AS "certificateNumber", issued_at AS "issuedAt", expires_at AS "expiresAt", status, metadata->>'verificationStatus' AS "verificationStatus" FROM certificates WHERE tenant_id = $1 AND staff_id = $2 ORDER BY created_at DESC `, [context.tenant.tenantId, context.staff.staffId] ); return result.rows; } export async function listStaffBankAccounts(actorUid) { const context = await requireStaffContext(actorUid); const result = await query( ` SELECT id AS "accountId", provider_name AS "bankName", provider_reference AS "providerReference", last4, is_primary AS "isPrimary", COALESCE(metadata->>'accountType', 'CHECKING') AS "accountType" FROM accounts WHERE tenant_id = $1 AND owner_staff_id = $2 ORDER BY is_primary DESC, created_at DESC `, [context.tenant.tenantId, context.staff.staffId] ); return result.rows; } export async function listStaffBenefits(actorUid) { const context = await requireStaffContext(actorUid); const result = await query( ` SELECT id AS "benefitId", benefit_type AS "benefitType", title, status, tracked_hours AS "trackedHours", target_hours AS "targetHours", metadata FROM staff_benefits WHERE tenant_id = $1 AND staff_id = $2 AND status = 'ACTIVE' ORDER BY created_at ASC `, [context.tenant.tenantId, context.staff.staffId] ); return result.rows; } export async function listCoreTeam(actorUid) { const context = await requireClientContext(actorUid); const result = await query( ` SELECT st.id AS "staffId", st.full_name AS "fullName", st.primary_role AS "primaryRole", st.average_rating AS "averageRating", st.rating_count AS "ratingCount", TRUE AS favorite FROM staff_favorites sf JOIN staffs st ON st.id = sf.staff_id WHERE sf.tenant_id = $1 AND sf.business_id = $2 ORDER BY st.average_rating DESC, st.full_name ASC `, [context.tenant.tenantId, context.business.businessId] ); return result.rows; } export async function getOrderReorderPreview(actorUid, orderId) { const context = await requireClientContext(actorUid); const result = await query( ` SELECT o.id AS "orderId", o.title, o.description, o.starts_at AS "startsAt", o.ends_at AS "endsAt", o.location_name AS "locationName", o.location_address AS "locationAddress", o.metadata, json_agg( json_build_object( 'shiftId', s.id, 'shiftCode', s.shift_code, 'title', s.title, 'startsAt', s.starts_at, 'endsAt', s.ends_at, 'roles', ( SELECT json_agg( json_build_object( 'roleId', sr.id, 'roleCode', sr.role_code, 'roleName', sr.role_name, 'workersNeeded', sr.workers_needed, 'payRateCents', sr.pay_rate_cents, 'billRateCents', sr.bill_rate_cents ) ORDER BY sr.role_name ASC ) FROM shift_roles sr WHERE sr.shift_id = s.id ) ) ORDER BY s.starts_at ASC ) AS shifts FROM orders o JOIN shifts s ON s.order_id = o.id WHERE o.tenant_id = $1 AND o.business_id = $2 AND o.id = $3 GROUP BY o.id `, [context.tenant.tenantId, context.business.businessId, orderId] ); if (result.rowCount === 0) { throw new AppError('NOT_FOUND', 'Order not found for reorder preview', 404, { orderId }); } return result.rows[0]; } export async function listBusinessTeamMembers(actorUid) { const context = await requireClientContext(actorUid); const result = await query( ` SELECT bm.id AS "businessMembershipId", u.id AS "userId", COALESCE(u.display_name, u.email) AS name, u.email, bm.business_role AS role FROM business_memberships bm JOIN users u ON u.id = bm.user_id WHERE bm.tenant_id = $1 AND bm.business_id = $2 AND bm.membership_status = 'ACTIVE' ORDER BY name ASC `, [context.tenant.tenantId, context.business.businessId] ); return result.rows; } export async function getReportSummary(actorUid, { startDate, endDate }) { const context = await requireClientContext(actorUid); const range = parseDateRange(startDate, endDate, 30); const [shifts, spend, performance, noShow] = await Promise.all([ query( ` SELECT COUNT(DISTINCT s.id)::INTEGER AS "totalShifts", COALESCE(AVG( CASE WHEN s.required_workers = 0 THEN 1 ELSE LEAST(s.assigned_workers::numeric / s.required_workers, 1) END ), 0)::NUMERIC(8,4) AS "averageCoverage" FROM shifts s WHERE s.tenant_id = $1 AND s.business_id = $2 AND s.starts_at >= $3::timestamptz AND s.starts_at <= $4::timestamptz `, [context.tenant.tenantId, context.business.businessId, range.start, range.end] ), query( ` SELECT COALESCE(SUM(total_cents), 0)::BIGINT AS "totalSpendCents" FROM invoices WHERE tenant_id = $1 AND business_id = $2 AND created_at >= $3::timestamptz AND created_at <= $4::timestamptz `, [context.tenant.tenantId, context.business.businessId, range.start, range.end] ), query( ` SELECT COALESCE(AVG(rating), 0)::NUMERIC(8,4) AS "averagePerformanceScore" FROM staff_reviews WHERE tenant_id = $1 AND business_id = $2 AND created_at >= $3::timestamptz AND created_at <= $4::timestamptz `, [context.tenant.tenantId, context.business.businessId, range.start, range.end] ), query( ` SELECT COUNT(*)::INTEGER AS "noShowCount" FROM assignments WHERE tenant_id = $1 AND business_id = $2 AND status = 'NO_SHOW' AND updated_at >= $3::timestamptz AND updated_at <= $4::timestamptz `, [context.tenant.tenantId, context.business.businessId, range.start, range.end] ), ]); return { totalShifts: Number(shifts.rows[0]?.totalShifts || 0), totalSpendCents: Number(spend.rows[0]?.totalSpendCents || 0), averageCoveragePercentage: Math.round(Number(shifts.rows[0]?.averageCoverage || 0) * 100), averagePerformanceScore: Number(performance.rows[0]?.averagePerformanceScore || 0), noShowCount: Number(noShow.rows[0]?.noShowCount || 0), forecastAccuracyPercentage: 90, }; } export async function getDailyOpsReport(actorUid, { date }) { const context = await requireClientContext(actorUid); const from = startOfDay(date).toISOString(); const to = endOfDay(date).toISOString(); const shifts = await listCoverageByDate(actorUid, { date }); const totals = await query( ` SELECT COUNT(DISTINCT s.id)::INTEGER AS "totalShifts", COUNT(DISTINCT a.id)::INTEGER AS "totalWorkersDeployed", COALESCE(SUM(ts.regular_minutes + ts.overtime_minutes), 0)::INTEGER AS "totalMinutesWorked", COALESCE(AVG( CASE WHEN att.check_in_at IS NULL THEN 0 WHEN att.check_in_at <= s.starts_at THEN 1 ELSE 0 END ), 0)::NUMERIC(8,4) AS "onTimeArrivalRate" FROM shifts s LEFT JOIN assignments a ON a.shift_id = s.id LEFT JOIN attendance_sessions att ON att.assignment_id = a.id LEFT JOIN timesheets ts ON ts.assignment_id = a.id WHERE s.tenant_id = $1 AND s.business_id = $2 AND s.starts_at >= $3::timestamptz AND s.starts_at < $4::timestamptz `, [context.tenant.tenantId, context.business.businessId, from, to] ); return { totalShifts: Number(totals.rows[0]?.totalShifts || 0), totalWorkersDeployed: Number(totals.rows[0]?.totalWorkersDeployed || 0), totalHoursWorked: Math.round(Number(totals.rows[0]?.totalMinutesWorked || 0) / 60), onTimeArrivalPercentage: Math.round(Number(totals.rows[0]?.onTimeArrivalRate || 0) * 100), shifts, }; } export async function getSpendReport(actorUid, { startDate, endDate, bucket = 'day' }) { const context = await requireClientContext(actorUid); const range = parseDateRange(startDate, endDate, 30); const bucketExpr = bucket === 'week' ? 'week' : 'day'; const [total, chart, breakdown] = await Promise.all([ query( ` SELECT COALESCE(SUM(total_cents), 0)::BIGINT AS "totalSpendCents" FROM invoices WHERE tenant_id = $1 AND business_id = $2 AND created_at >= $3::timestamptz AND created_at <= $4::timestamptz `, [context.tenant.tenantId, context.business.businessId, range.start, range.end] ), query( ` SELECT date_trunc('${bucketExpr}', created_at) AS bucket, COALESCE(SUM(total_cents), 0)::BIGINT AS "amountCents" FROM invoices WHERE tenant_id = $1 AND business_id = $2 AND created_at >= $3::timestamptz AND created_at <= $4::timestamptz GROUP BY 1 ORDER BY 1 ASC `, [context.tenant.tenantId, context.business.businessId, range.start, range.end] ), getSpendBreakdown(actorUid, { startDate, endDate }), ]); return { totalSpendCents: Number(total.rows[0]?.totalSpendCents || 0), chart: chart.rows, breakdown, }; } export async function getCoverageReport(actorUid, { startDate, endDate }) { const context = await requireClientContext(actorUid); const range = parseDateRange(startDate, endDate, 30); const result = await query( ` WITH daily AS ( SELECT date_trunc('day', starts_at) AS day, SUM(required_workers)::INTEGER AS needed, SUM(assigned_workers)::INTEGER AS filled FROM shifts WHERE tenant_id = $1 AND business_id = $2 AND starts_at >= $3::timestamptz AND starts_at <= $4::timestamptz GROUP BY 1 ) SELECT day, needed, filled, CASE WHEN needed = 0 THEN 0 ELSE ROUND((filled::numeric / needed) * 100, 2) END AS "coveragePercentage" FROM daily ORDER BY day ASC `, [context.tenant.tenantId, context.business.businessId, range.start, range.end] ); const totals = result.rows.reduce((acc, row) => { acc.neededWorkers += Number(row.needed || 0); acc.filledWorkers += Number(row.filled || 0); return acc; }, { neededWorkers: 0, filledWorkers: 0 }); return { averageCoveragePercentage: totals.neededWorkers === 0 ? 0 : Math.round((totals.filledWorkers / totals.neededWorkers) * 100), filledWorkers: totals.filledWorkers, neededWorkers: totals.neededWorkers, chart: result.rows, }; } export async function getForecastReport(actorUid, { startDate, endDate }) { const context = await requireClientContext(actorUid); const range = parseDateRange(startDate, endDate, 42); const weekly = await query( ` SELECT date_trunc('week', s.starts_at) AS week, COUNT(DISTINCT s.id)::INTEGER AS "shiftCount", COALESCE(SUM(sr.workers_needed * EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600), 0)::NUMERIC(12,2) AS "workerHours", COALESCE(SUM(sr.bill_rate_cents * sr.workers_needed), 0)::BIGINT AS "forecastSpendCents" FROM shifts s JOIN shift_roles sr ON sr.shift_id = s.id WHERE s.tenant_id = $1 AND s.business_id = $2 AND s.starts_at >= $3::timestamptz AND s.starts_at <= $4::timestamptz GROUP BY 1 ORDER BY 1 ASC `, [context.tenant.tenantId, context.business.businessId, range.start, range.end] ); const totals = weekly.rows.reduce((acc, row) => { acc.forecastSpendCents += Number(row.forecastSpendCents || 0); acc.totalShifts += Number(row.shiftCount || 0); acc.totalWorkerHours += Number(row.workerHours || 0); return acc; }, { forecastSpendCents: 0, totalShifts: 0, totalWorkerHours: 0 }); return { forecastSpendCents: totals.forecastSpendCents, averageWeeklySpendCents: weekly.rows.length === 0 ? 0 : Math.round(totals.forecastSpendCents / weekly.rows.length), totalShifts: totals.totalShifts, totalWorkerHours: totals.totalWorkerHours, weeks: weekly.rows.map((row) => ({ ...row, averageShiftCostCents: Number(row.shiftCount || 0) === 0 ? 0 : Math.round(Number(row.forecastSpendCents || 0) / Number(row.shiftCount || 0)), })), }; } export async function getPerformanceReport(actorUid, { startDate, endDate }) { const context = await requireClientContext(actorUid); const range = parseDateRange(startDate, endDate, 30); const totals = await query( ` WITH base AS ( SELECT COUNT(DISTINCT s.id)::INTEGER AS total_shifts, COUNT(DISTINCT s.id) FILTER (WHERE s.assigned_workers >= s.required_workers)::INTEGER AS filled_shifts, COUNT(DISTINCT s.id) FILTER (WHERE s.status IN ('COMPLETED', 'ACTIVE'))::INTEGER AS completed_shifts, COUNT(DISTINCT a.id) FILTER ( WHERE att.check_in_at IS NOT NULL AND att.check_in_at <= s.starts_at )::INTEGER AS on_time_assignments, COUNT(DISTINCT a.id)::INTEGER AS total_assignments, COUNT(DISTINCT a.id) FILTER (WHERE a.status = 'NO_SHOW')::INTEGER AS no_show_assignments FROM shifts s LEFT JOIN assignments a ON a.shift_id = s.id LEFT JOIN attendance_sessions att ON att.assignment_id = a.id WHERE s.tenant_id = $1 AND s.business_id = $2 AND s.starts_at >= $3::timestamptz AND s.starts_at <= $4::timestamptz ), fill_times AS ( SELECT COALESCE(AVG(EXTRACT(EPOCH FROM (a.assigned_at - s.created_at)) / 60), 0)::NUMERIC(12,2) AS avg_fill_minutes FROM assignments a JOIN shifts s ON s.id = a.shift_id WHERE a.tenant_id = $1 AND a.business_id = $2 AND s.starts_at >= $3::timestamptz AND s.starts_at <= $4::timestamptz ), reviews AS ( SELECT COALESCE(AVG(rating), 0)::NUMERIC(8,4) AS avg_rating FROM staff_reviews WHERE tenant_id = $1 AND business_id = $2 AND created_at >= $3::timestamptz AND created_at <= $4::timestamptz ) SELECT * FROM base, fill_times, reviews `, [context.tenant.tenantId, context.business.businessId, range.start, range.end] ); const row = totals.rows[0] || {}; const totalShifts = Number(row.total_shifts || 0); const totalAssignments = Number(row.total_assignments || 0); return { averagePerformanceScore: Number(row.avg_rating || 0), fillRatePercentage: totalShifts === 0 ? 0 : Math.round((Number(row.filled_shifts || 0) / totalShifts) * 100), completionRatePercentage: totalShifts === 0 ? 0 : Math.round((Number(row.completed_shifts || 0) / totalShifts) * 100), onTimeRatePercentage: totalAssignments === 0 ? 0 : Math.round((Number(row.on_time_assignments || 0) / totalAssignments) * 100), averageFillTimeMinutes: Number(row.avg_fill_minutes || 0), totalShiftsCovered: Number(row.completed_shifts || 0), noShowRatePercentage: totalAssignments === 0 ? 0 : Math.round((Number(row.no_show_assignments || 0) / totalAssignments) * 100), }; } export async function getNoShowReport(actorUid, { startDate, endDate }) { const context = await requireClientContext(actorUid); const range = parseDateRange(startDate, endDate, 30); const incidents = await query( ` SELECT st.id AS "staffId", st.full_name AS "staffName", COUNT(*)::INTEGER AS "incidentCount", json_agg( json_build_object( 'shiftId', s.id, 'shiftTitle', s.title, 'roleName', sr.role_name, 'date', s.starts_at ) ORDER BY s.starts_at DESC ) AS incidents FROM assignments a JOIN staffs st ON st.id = a.staff_id JOIN shifts s ON s.id = a.shift_id JOIN shift_roles sr ON sr.id = a.shift_role_id WHERE a.tenant_id = $1 AND a.business_id = $2 AND a.status = 'NO_SHOW' AND s.starts_at >= $3::timestamptz AND s.starts_at <= $4::timestamptz GROUP BY st.id ORDER BY "incidentCount" DESC, "staffName" ASC `, [context.tenant.tenantId, context.business.businessId, range.start, range.end] ); const totalNoShowCount = incidents.rows.reduce((acc, row) => acc + Number(row.incidentCount || 0), 0); const totalWorkers = incidents.rows.length; const totalAssignments = await query( ` SELECT COUNT(*)::INTEGER AS total FROM assignments WHERE tenant_id = $1 AND business_id = $2 AND created_at >= $3::timestamptz AND created_at <= $4::timestamptz `, [context.tenant.tenantId, context.business.businessId, range.start, range.end] ); return { totalNoShowCount, noShowRatePercentage: Number(totalAssignments.rows[0]?.total || 0) === 0 ? 0 : Math.round((totalNoShowCount / Number(totalAssignments.rows[0].total)) * 100), workersWhoNoShowed: totalWorkers, items: incidents.rows.map((row) => ({ ...row, riskStatus: Number(row.incidentCount || 0) >= 2 ? 'HIGH' : 'MEDIUM', })), }; } export async function listEmergencyContacts(actorUid) { const context = await requireStaffContext(actorUid); const result = await query( ` SELECT id AS "contactId", full_name AS "fullName", phone, relationship_type AS "relationshipType", is_primary AS "isPrimary" FROM emergency_contacts WHERE tenant_id = $1 AND staff_id = $2 ORDER BY is_primary DESC, created_at ASC `, [context.tenant.tenantId, context.staff.staffId] ); return result.rows; } export async function listTaxForms(actorUid) { const context = await requireStaffContext(actorUid); const docs = ['I-9', 'W-4']; const result = await query( ` SELECT d.id AS "documentId", d.name AS "formType", sd.id AS "staffDocumentId", COALESCE(sd.metadata->>'formStatus', 'NOT_STARTED') AS status, COALESCE(sd.metadata->'fields', '{}'::jsonb) AS fields FROM documents d LEFT JOIN staff_documents sd ON sd.document_id = d.id AND sd.staff_id = $2 AND sd.tenant_id = $1 WHERE d.tenant_id = $1 AND d.document_type = 'TAX_FORM' AND d.name = ANY($3::text[]) ORDER BY d.name ASC `, [context.tenant.tenantId, context.staff.staffId, docs] ); return result.rows; } export async function listAttireChecklist(actorUid) { const context = await requireStaffContext(actorUid); const result = await query( ` SELECT d.id AS "documentId", d.name, COALESCE(d.metadata->>'description', '') AS description, COALESCE((d.metadata->>'required')::boolean, TRUE) AS mandatory, sd.id AS "staffDocumentId", sd.file_uri AS "photoUri", COALESCE(sd.status, 'NOT_UPLOADED') AS status, sd.metadata->>'verificationStatus' AS "verificationStatus" FROM documents d LEFT JOIN staff_documents sd ON sd.document_id = d.id AND sd.staff_id = $2 AND sd.tenant_id = $1 WHERE d.tenant_id = $1 AND d.document_type = 'ATTIRE' ORDER BY d.name ASC `, [context.tenant.tenantId, context.staff.staffId] ); return result.rows; } export async function listTimeCardEntries(actorUid, { month, year }) { const context = await requireStaffContext(actorUid); const monthValue = Number.parseInt(`${month || new Date().getUTCMonth() + 1}`, 10); const yearValue = Number.parseInt(`${year || new Date().getUTCFullYear()}`, 10); const start = new Date(Date.UTC(yearValue, monthValue - 1, 1)); const end = new Date(Date.UTC(yearValue, monthValue, 1)); const result = await query( ` SELECT s.starts_at::date AS date, s.title AS "shiftName", COALESCE(cp.label, s.location_name) AS location, att.check_in_at AS "clockInAt", att.check_out_at AS "clockOutAt", COALESCE(ts.regular_minutes + ts.overtime_minutes, 0) AS "minutesWorked", sr.pay_rate_cents AS "hourlyRateCents", COALESCE(ts.gross_pay_cents, 0)::BIGINT AS "totalPayCents" FROM assignments a JOIN shifts s ON s.id = a.shift_id LEFT JOIN shift_roles sr ON sr.id = a.shift_role_id LEFT JOIN attendance_sessions att ON att.assignment_id = a.id LEFT JOIN timesheets ts ON ts.assignment_id = a.id LEFT JOIN clock_points cp ON cp.id = s.clock_point_id WHERE a.tenant_id = $1 AND a.staff_id = $2 AND s.starts_at >= $3::timestamptz AND s.starts_at < $4::timestamptz AND a.status IN ('CHECKED_OUT', 'COMPLETED') ORDER BY s.starts_at DESC `, [context.tenant.tenantId, context.staff.staffId, start.toISOString(), end.toISOString()] ); return result.rows; } export async function getPrivacySettings(actorUid) { const context = await requireStaffContext(actorUid); return { profileVisible: metadataBoolean(context.staff.metadata || {}, 'profileVisible', true), }; } export async function listFaqCategories() { return FAQ_CATEGORIES; } export async function searchFaqs(queryText) { const needle = `${queryText || ''}`.trim().toLowerCase(); if (!needle) { return FAQ_CATEGORIES; } return FAQ_CATEGORIES .map((category) => ({ category: category.category, items: category.items.filter((item) => { const haystack = `${item.question} ${item.answer}`.toLowerCase(); return haystack.includes(needle); }), })) .filter((category) => category.items.length > 0); }