1703 lines
56 KiB
JavaScript
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);
|
|
}
|