Files
Krow-workspace/backend/query-api/src/services/mobile-query-service.js
2026-03-13 17:02:24 +01:00

1703 lines
56 KiB
JavaScript

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);
}