feat(api): complete unified v2 mobile surface

This commit is contained in:
zouantchaw
2026-03-13 17:02:24 +01:00
parent 817a39e305
commit b455455a49
39 changed files with 7726 additions and 506 deletions

View File

@@ -0,0 +1,41 @@
export const FAQ_CATEGORIES = [
{
category: 'Getting Started',
items: [
{
question: 'How do I complete my worker profile?',
answer: 'Finish your personal info, preferred locations, experience, emergency contact, attire, and tax forms so shift applications and clock-in become available.',
},
{
question: 'Why can I not apply to shifts yet?',
answer: 'The worker profile must be complete before the platform allows applications and shift acceptance. Missing sections are returned by the profile completion endpoints.',
},
],
},
{
category: 'Shifts And Attendance',
items: [
{
question: 'How does clock-in work?',
answer: 'Clock-in validates that you are assigned to the shift, near the configured hub geofence, and using the expected clock-in source such as near-field communication when required.',
},
{
question: 'What happens if I request a swap?',
answer: 'The assignment moves to swap-requested status so operations can refill the shift while keeping an audit trail of the original assignment.',
},
],
},
{
category: 'Payments And Compliance',
items: [
{
question: 'When do I see my earnings?',
answer: 'Completed and processed time records appear in the worker payments summary, history, and time-card screens after attendance closes and payment processing runs.',
},
{
question: 'How are documents and certificates verified?',
answer: 'Uploads create verification jobs that run automatic checks first and then allow manual review when confidence is low or a provider is unavailable.',
},
],
},
];

View File

@@ -1,32 +1,47 @@
import { Router } from 'express';
import { requireAuth, requirePolicy } from '../middleware/auth.js';
import {
getCoverageReport,
getClientDashboard,
getClientSession,
getCoverageStats,
getCurrentAttendanceStatus,
getCurrentBill,
getDailyOpsReport,
getPaymentChart,
getPaymentsSummary,
getPersonalInfo,
getPerformanceReport,
getProfileSectionsStatus,
getPrivacySettings,
getForecastReport,
getNoShowReport,
getOrderReorderPreview,
getReportSummary,
getSavings,
getStaffDashboard,
getStaffProfileCompletion,
getStaffSession,
getStaffShiftDetail,
listAttireChecklist,
listAssignedShifts,
listBusinessAccounts,
listBusinessTeamMembers,
listCancelledShifts,
listCertificates,
listCostCenters,
listCoreTeam,
listCoverageByDate,
listCompletedShifts,
listEmergencyContacts,
listFaqCategories,
listHubManagers,
listHubs,
listIndustries,
listInvoiceHistory,
listOpenShifts,
listTaxForms,
listTimeCardEntries,
listOrderItemsByDateRange,
listPaymentsHistory,
listPendingAssignments,
@@ -40,37 +55,55 @@ import {
listTodayShifts,
listVendorRoles,
listVendors,
searchFaqs,
getSpendBreakdown,
getSpendReport,
} from '../services/mobile-query-service.js';
const defaultQueryService = {
getClientDashboard,
getClientSession,
getCoverageReport,
getCoverageStats,
getCurrentAttendanceStatus,
getCurrentBill,
getDailyOpsReport,
getPaymentChart,
getPaymentsSummary,
getPersonalInfo,
getPerformanceReport,
getProfileSectionsStatus,
getPrivacySettings,
getForecastReport,
getNoShowReport,
getOrderReorderPreview,
getReportSummary,
getSavings,
getSpendBreakdown,
getSpendReport,
getStaffDashboard,
getStaffProfileCompletion,
getStaffSession,
getStaffShiftDetail,
listAttireChecklist,
listAssignedShifts,
listBusinessAccounts,
listBusinessTeamMembers,
listCancelledShifts,
listCertificates,
listCostCenters,
listCoreTeam,
listCoverageByDate,
listCompletedShifts,
listEmergencyContacts,
listFaqCategories,
listHubManagers,
listHubs,
listIndustries,
listInvoiceHistory,
listOpenShifts,
listTaxForms,
listTimeCardEntries,
listOrderItemsByDateRange,
listPaymentsHistory,
listPendingAssignments,
@@ -84,6 +117,7 @@ const defaultQueryService = {
listTodayShifts,
listVendorRoles,
listVendors,
searchFaqs,
};
function requireQueryParam(name, value) {
@@ -199,6 +233,15 @@ export function createMobileQueryRouter(queryService = defaultQueryService) {
}
});
router.get('/client/coverage/core-team', requireAuth, requirePolicy('coverage.read', 'coverage'), async (req, res, next) => {
try {
const items = await queryService.listCoreTeam(req.actor.uid);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/hubs', requireAuth, requirePolicy('hubs.read', 'hub'), async (req, res, next) => {
try {
const items = await queryService.listHubs(req.actor.uid);
@@ -244,6 +287,15 @@ export function createMobileQueryRouter(queryService = defaultQueryService) {
}
});
router.get('/client/team-members', requireAuth, requirePolicy('hubs.read', 'hub'), async (req, res, next) => {
try {
const items = await queryService.listBusinessTeamMembers(req.actor.uid);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/orders/view', requireAuth, requirePolicy('orders.read', 'order'), async (req, res, next) => {
try {
const items = await queryService.listOrderItemsByDateRange(req.actor.uid, req.query);
@@ -253,6 +305,78 @@ export function createMobileQueryRouter(queryService = defaultQueryService) {
}
});
router.get('/client/orders/:orderId/reorder-preview', requireAuth, requirePolicy('orders.read', 'order'), async (req, res, next) => {
try {
const data = await queryService.getOrderReorderPreview(req.actor.uid, req.params.orderId);
return res.status(200).json({ ...data, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/reports/summary', requireAuth, requirePolicy('reports.read', 'report'), async (req, res, next) => {
try {
const data = await queryService.getReportSummary(req.actor.uid, req.query);
return res.status(200).json({ ...data, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/reports/daily-ops', requireAuth, requirePolicy('reports.read', 'report'), async (req, res, next) => {
try {
const data = await queryService.getDailyOpsReport(req.actor.uid, { date: requireQueryParam('date', req.query.date) });
return res.status(200).json({ ...data, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/reports/spend', requireAuth, requirePolicy('reports.read', 'report'), async (req, res, next) => {
try {
const data = await queryService.getSpendReport(req.actor.uid, req.query);
return res.status(200).json({ ...data, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/reports/coverage', requireAuth, requirePolicy('reports.read', 'report'), async (req, res, next) => {
try {
const data = await queryService.getCoverageReport(req.actor.uid, req.query);
return res.status(200).json({ ...data, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/reports/forecast', requireAuth, requirePolicy('reports.read', 'report'), async (req, res, next) => {
try {
const data = await queryService.getForecastReport(req.actor.uid, req.query);
return res.status(200).json({ ...data, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/reports/performance', requireAuth, requirePolicy('reports.read', 'report'), async (req, res, next) => {
try {
const data = await queryService.getPerformanceReport(req.actor.uid, req.query);
return res.status(200).json({ ...data, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/client/reports/no-show', requireAuth, requirePolicy('reports.read', 'report'), async (req, res, next) => {
try {
const data = await queryService.getNoShowReport(req.actor.uid, req.query);
return res.status(200).json({ ...data, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/session', requireAuth, requirePolicy('staff.session.read', 'session'), async (req, res, next) => {
try {
const data = await queryService.getStaffSession(req.actor.uid);
@@ -433,6 +557,33 @@ export function createMobileQueryRouter(queryService = defaultQueryService) {
}
});
router.get('/staff/profile/attire', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
try {
const items = await queryService.listAttireChecklist(req.actor.uid);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/profile/tax-forms', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
try {
const items = await queryService.listTaxForms(req.actor.uid);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/profile/emergency-contacts', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
try {
const items = await queryService.listEmergencyContacts(req.actor.uid);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/profile/certificates', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
try {
const items = await queryService.listCertificates(req.actor.uid);
@@ -460,5 +611,41 @@ export function createMobileQueryRouter(queryService = defaultQueryService) {
}
});
router.get('/staff/profile/time-card', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
try {
const items = await queryService.listTimeCardEntries(req.actor.uid, req.query);
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/profile/privacy', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => {
try {
const data = await queryService.getPrivacySettings(req.actor.uid);
return res.status(200).json({ ...data, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/faqs', async (req, res, next) => {
try {
const items = await queryService.listFaqCategories();
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/faqs/search', async (req, res, next) => {
try {
const items = await queryService.searchFaqs(req.query.q || '');
return res.status(200).json({ items, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
return router;
}

View File

@@ -1,4 +1,16 @@
import { Pool } from 'pg';
import pg from 'pg';
const { Pool, types } = pg;
function parseNumericDatabaseValue(value) {
if (value == null) return value;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : value;
}
// Mobile/frontend routes expect numeric JSON values for database aggregates.
types.setTypeParser(types.builtins.INT8, parseNumericDatabaseValue);
types.setTypeParser(types.builtins.NUMERIC, parseNumericDatabaseValue);
let pool;

View File

@@ -1,4 +1,5 @@
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';
@@ -45,6 +46,12 @@ function metadataArray(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+/);
@@ -775,34 +782,73 @@ export async function listOpenShifts(actorUid, { limit, search } = {}) {
const context = await requireStaffContext(actorUid);
const result = await query(
`
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 ($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')
)
AND sr.role_code = $4
ORDER BY s.starts_at ASC
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
`,
[
@@ -987,17 +1033,21 @@ export async function listProfileDocuments(actorUid) {
const result = await query(
`
SELECT
sd.id AS "staffDocumentId",
d.id AS "documentId",
d.document_type AS "documentType",
d.name,
sd.id AS "staffDocumentId",
sd.file_uri AS "fileUri",
sd.status,
sd.expires_at AS "expiresAt"
FROM staff_documents sd
JOIN documents d ON d.id = sd.document_id
WHERE sd.tenant_id = $1
AND sd.staff_id = $2
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]
@@ -1012,10 +1062,14 @@ export async function listCertificates(actorUid) {
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
status,
metadata->>'verificationStatus' AS "verificationStatus"
FROM certificates
WHERE tenant_id = $1
AND staff_id = $2
@@ -1069,3 +1123,580 @@ export async function listStaffBenefits(actorUid) {
);
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);
}

View File

@@ -10,13 +10,21 @@ function createMobileQueryService() {
getClientDashboard: async () => ({ businessName: 'Google Cafes' }),
getClientSession: async () => ({ business: { businessId: 'b1' } }),
getCoverageStats: async () => ({ totalCoveragePercentage: 100 }),
getCoverageReport: async () => ({ items: [{ shiftId: 'coverage-1' }] }),
getCurrentAttendanceStatus: async () => ({ attendanceStatus: 'NOT_CLOCKED_IN' }),
getCurrentBill: async () => ({ currentBillCents: 1000 }),
getDailyOpsReport: async () => ({ totals: { workedAssignments: 4 } }),
getForecastReport: async () => ({ totals: { projectedCoveragePercentage: 92 } }),
getNoShowReport: async () => ({ totals: { noShows: 1 } }),
getPaymentChart: async () => ([{ amountCents: 100 }]),
getPaymentsSummary: async () => ({ totalEarningsCents: 500 }),
getPersonalInfo: async () => ({ firstName: 'Ana' }),
getPerformanceReport: async () => ({ totals: { averageRating: 4.8 } }),
getProfileSectionsStatus: async () => ({ personalInfoCompleted: true }),
getPrivacySettings: async () => ({ profileVisibility: 'TEAM_ONLY' }),
getReportSummary: async () => ({ reportDate: '2026-03-13', totals: { orders: 3 } }),
getSavings: async () => ({ savingsCents: 200 }),
getSpendReport: async () => ({ totals: { amountCents: 2000 } }),
getSpendBreakdown: async () => ([{ category: 'Barista', amountCents: 1000 }]),
getStaffDashboard: async () => ({ staffName: 'Ana Barista' }),
getStaffProfileCompletion: async () => ({ completed: true }),
@@ -28,25 +36,34 @@ function createMobileQueryService() {
listCertificates: async () => ([{ certificateId: 'cert-1' }]),
listCostCenters: async () => ([{ costCenterId: 'cc-1' }]),
listCoverageByDate: async () => ([{ shiftId: 'coverage-1' }]),
listCoreTeam: async () => ([{ staffId: 'core-1' }]),
listCompletedShifts: async () => ([{ shiftId: 'completed-1' }]),
listEmergencyContacts: async () => ([{ contactId: 'ec-1' }]),
listFaqCategories: async () => ([{ id: 'faq-1', title: 'Clock in' }]),
listHubManagers: async () => ([{ managerId: 'm1' }]),
listHubs: async () => ([{ hubId: 'hub-1' }]),
listIndustries: async () => (['CATERING']),
listInvoiceHistory: async () => ([{ invoiceId: 'inv-1' }]),
listOpenShifts: async () => ([{ shiftId: 'open-1' }]),
getOrderReorderPreview: async () => ({ orderId: 'order-1', lines: 2 }),
listOrderItemsByDateRange: async () => ([{ itemId: 'item-1' }]),
listPaymentsHistory: async () => ([{ paymentId: 'pay-1' }]),
listPendingAssignments: async () => ([{ assignmentId: 'asg-1' }]),
listPendingInvoices: async () => ([{ invoiceId: 'pending-1' }]),
listProfileDocuments: async () => ([{ staffDocumentId: 'doc-1' }]),
listRecentReorders: async () => ([{ id: 'order-1' }]),
listBusinessTeamMembers: async () => ([{ userId: 'u-1' }]),
listSkills: async () => (['BARISTA']),
listStaffAvailability: async () => ([{ dayOfWeek: 1 }]),
listStaffBankAccounts: async () => ([{ accountId: 'acc-2' }]),
listStaffBenefits: async () => ([{ benefitId: 'benefit-1' }]),
listTaxForms: async () => ([{ formType: 'W4' }]),
listAttireChecklist: async () => ([{ documentId: 'attire-1' }]),
listTimeCardEntries: async () => ([{ entryId: 'tc-1' }]),
listTodayShifts: async () => ([{ shiftId: 'today-1' }]),
listVendorRoles: async () => ([{ roleId: 'role-1' }]),
listVendors: async () => ([{ vendorId: 'vendor-1' }]),
searchFaqs: async () => ([{ id: 'faq-2', title: 'Payments' }]),
};
}
@@ -89,3 +106,43 @@ test('GET /query/staff/shifts/:shiftId returns injected shift detail', async ()
assert.equal(res.status, 200);
assert.equal(res.body.shiftId, 'shift-1');
});
test('GET /query/client/reports/summary returns injected report summary', async () => {
const app = createApp({ mobileQueryService: createMobileQueryService() });
const res = await request(app)
.get('/query/client/reports/summary?date=2026-03-13')
.set('Authorization', 'Bearer test-token');
assert.equal(res.status, 200);
assert.equal(res.body.totals.orders, 3);
});
test('GET /query/client/coverage/core-team returns injected core team list', async () => {
const app = createApp({ mobileQueryService: createMobileQueryService() });
const res = await request(app)
.get('/query/client/coverage/core-team?date=2026-03-13')
.set('Authorization', 'Bearer test-token');
assert.equal(res.status, 200);
assert.equal(res.body.items[0].staffId, 'core-1');
});
test('GET /query/staff/profile/tax-forms returns injected tax forms', async () => {
const app = createApp({ mobileQueryService: createMobileQueryService() });
const res = await request(app)
.get('/query/staff/profile/tax-forms')
.set('Authorization', 'Bearer test-token');
assert.equal(res.status, 200);
assert.equal(res.body.items[0].formType, 'W4');
});
test('GET /query/staff/faqs/search returns injected faq search results', async () => {
const app = createApp({ mobileQueryService: createMobileQueryService() });
const res = await request(app)
.get('/query/staff/faqs/search?q=payments')
.set('Authorization', 'Bearer test-token');
assert.equal(res.status, 200);
assert.equal(res.body.items[0].title, 'Payments');
});