feat(api): complete unified v2 mobile surface
This commit is contained in:
41
backend/query-api/src/data/faqs.js
Normal file
41
backend/query-api/src/data/faqs.js
Normal 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.',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user