Merge branch 'dev' into 595-sred-research-implement-cross-platform-nfc-clocking-interface-fe
This commit is contained in:
@@ -17,6 +17,6 @@ class AppConfig {
|
|||||||
/// The base URL for the V2 Unified API gateway.
|
/// The base URL for the V2 Unified API gateway.
|
||||||
static const String v2ApiBaseUrl = String.fromEnvironment(
|
static const String v2ApiBaseUrl = String.fromEnvironment(
|
||||||
'V2_API_BASE_URL',
|
'V2_API_BASE_URL',
|
||||||
defaultValue: 'https://krow-api-v2-933560802882.us-central1.run.app',
|
defaultValue: 'https://krow-api-v2-e3g6witsvq-uc.a.run.app',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import { errorHandler, notFoundHandler } from './middleware/error-handler.js';
|
|||||||
import { healthRouter } from './routes/health.js';
|
import { healthRouter } from './routes/health.js';
|
||||||
import { createCommandsRouter } from './routes/commands.js';
|
import { createCommandsRouter } from './routes/commands.js';
|
||||||
import { createMobileCommandsRouter } from './routes/mobile.js';
|
import { createMobileCommandsRouter } from './routes/mobile.js';
|
||||||
|
import { assertSafeRuntimeConfig } from './lib/runtime-safety.js';
|
||||||
|
|
||||||
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
|
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
|
||||||
|
|
||||||
export function createApp(options = {}) {
|
export function createApp(options = {}) {
|
||||||
|
assertSafeRuntimeConfig();
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
app.use(requestContext);
|
app.use(requestContext);
|
||||||
|
|||||||
@@ -202,6 +202,11 @@ export const shiftApplySchema = z.object({
|
|||||||
instantBook: z.boolean().optional(),
|
instantBook: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const orderBookSchema = z.object({
|
||||||
|
orderId: z.string().uuid(),
|
||||||
|
roleId: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
export const shiftDecisionSchema = z.object({
|
export const shiftDecisionSchema = z.object({
|
||||||
shiftId: z.string().uuid(),
|
shiftId: z.string().uuid(),
|
||||||
reason: z.string().max(1000).optional(),
|
reason: z.string().max(1000).optional(),
|
||||||
|
|||||||
44
backend/command-api/src/lib/runtime-safety.js
Normal file
44
backend/command-api/src/lib/runtime-safety.js
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
function runtimeEnvName() {
|
||||||
|
return `${process.env.APP_ENV || process.env.NODE_ENV || ''}`.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProtectedEnv() {
|
||||||
|
return ['staging', 'prod', 'production'].includes(runtimeEnvName());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertSafeRuntimeConfig() {
|
||||||
|
if (!isProtectedEnv()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
if (process.env.AUTH_BYPASS === 'true') {
|
||||||
|
errors.push('AUTH_BYPASS must be disabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (`${process.env.IDEMPOTENCY_STORE || ''}`.trim().toLowerCase() === 'memory') {
|
||||||
|
errors.push('IDEMPOTENCY_STORE must not be memory');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
throw new Error(`Unsafe command-api runtime config for ${runtimeEnvName()}: ${errors.join('; ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertSafeWorkerRuntimeConfig() {
|
||||||
|
if (!isProtectedEnv()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors = [];
|
||||||
|
const deliveryMode = `${process.env.PUSH_DELIVERY_MODE || 'live'}`.trim().toLowerCase();
|
||||||
|
|
||||||
|
if (deliveryMode !== 'live') {
|
||||||
|
errors.push('PUSH_DELIVERY_MODE must be live');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
throw new Error(`Unsafe notification-worker runtime config for ${runtimeEnvName()}: ${errors.join('; ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,30 @@ function getBearerToken(header) {
|
|||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildBypassActor() {
|
||||||
|
let policyContext = {
|
||||||
|
user: { userId: 'test-user' },
|
||||||
|
tenant: { tenantId: '*' },
|
||||||
|
business: { businessId: '*' },
|
||||||
|
staff: { staffId: '*', workforceId: '*' },
|
||||||
|
};
|
||||||
|
|
||||||
|
if (process.env.AUTH_BYPASS_CONTEXT) {
|
||||||
|
try {
|
||||||
|
policyContext = JSON.parse(process.env.AUTH_BYPASS_CONTEXT);
|
||||||
|
} catch (_error) {
|
||||||
|
policyContext = {
|
||||||
|
user: { userId: 'test-user' },
|
||||||
|
tenant: { tenantId: '*' },
|
||||||
|
business: { businessId: '*' },
|
||||||
|
staff: { staffId: '*', workforceId: '*' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { uid: 'test-user', email: 'test@krow.local', role: 'TEST', policyContext };
|
||||||
|
}
|
||||||
|
|
||||||
export async function requireAuth(req, _res, next) {
|
export async function requireAuth(req, _res, next) {
|
||||||
try {
|
try {
|
||||||
const token = getBearerToken(req.get('Authorization'));
|
const token = getBearerToken(req.get('Authorization'));
|
||||||
@@ -17,7 +41,7 @@ export async function requireAuth(req, _res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (process.env.AUTH_BYPASS === 'true') {
|
if (process.env.AUTH_BYPASS === 'true') {
|
||||||
req.actor = { uid: 'test-user', email: 'test@krow.local', role: 'TEST' };
|
req.actor = buildBypassActor();
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,10 +60,14 @@ export async function requireAuth(req, _res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function requirePolicy(action, resource) {
|
export function requirePolicy(action, resource) {
|
||||||
return (req, _res, next) => {
|
return async (req, _res, next) => {
|
||||||
if (!can(action, resource, req.actor)) {
|
try {
|
||||||
|
if (!(await can(action, resource, req.actor, req))) {
|
||||||
return next(new AppError('FORBIDDEN', 'Not allowed to perform this action', 403));
|
return next(new AppError('FORBIDDEN', 'Not allowed to perform this action', 403));
|
||||||
}
|
}
|
||||||
return next();
|
return next();
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
addStaffBankAccount,
|
addStaffBankAccount,
|
||||||
approveInvoice,
|
approveInvoice,
|
||||||
applyForShift,
|
applyForShift,
|
||||||
|
bookOrder,
|
||||||
assignHubManager,
|
assignHubManager,
|
||||||
assignHubNfc,
|
assignHubNfc,
|
||||||
cancelLateWorker,
|
cancelLateWorker,
|
||||||
@@ -76,6 +77,7 @@ import {
|
|||||||
profileExperienceSchema,
|
profileExperienceSchema,
|
||||||
pushTokenDeleteSchema,
|
pushTokenDeleteSchema,
|
||||||
pushTokenRegisterSchema,
|
pushTokenRegisterSchema,
|
||||||
|
orderBookSchema,
|
||||||
shiftManagerCreateSchema,
|
shiftManagerCreateSchema,
|
||||||
shiftApplySchema,
|
shiftApplySchema,
|
||||||
shiftDecisionSchema,
|
shiftDecisionSchema,
|
||||||
@@ -95,6 +97,7 @@ const defaultHandlers = {
|
|||||||
addStaffBankAccount,
|
addStaffBankAccount,
|
||||||
approveInvoice,
|
approveInvoice,
|
||||||
applyForShift,
|
applyForShift,
|
||||||
|
bookOrder,
|
||||||
assignHubManager,
|
assignHubManager,
|
||||||
assignHubNfc,
|
assignHubNfc,
|
||||||
cancelLateWorker,
|
cancelLateWorker,
|
||||||
@@ -438,6 +441,14 @@ export function createMobileCommandsRouter(handlers = defaultHandlers) {
|
|||||||
paramShape: (req) => ({ ...req.body, shiftId: req.params.shiftId }),
|
paramShape: (req) => ({ ...req.body, shiftId: req.params.shiftId }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
router.post(...mobileCommand('/staff/orders/:orderId/book', {
|
||||||
|
schema: orderBookSchema,
|
||||||
|
policyAction: 'staff.orders.book',
|
||||||
|
resource: 'order',
|
||||||
|
handler: handlers.bookOrder,
|
||||||
|
paramShape: (req) => ({ ...req.body, orderId: req.params.orderId }),
|
||||||
|
}));
|
||||||
|
|
||||||
router.post(...mobileCommand('/staff/shifts/:shiftId/accept', {
|
router.post(...mobileCommand('/staff/shifts/:shiftId/accept', {
|
||||||
schema: shiftDecisionSchema,
|
schema: shiftDecisionSchema,
|
||||||
policyAction: 'staff.shifts.accept',
|
policyAction: 'staff.shifts.accept',
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { recordGeofenceIncident } from './attendance-monitoring.js';
|
|||||||
import { recordAttendanceSecurityProof } from './attendance-security.js';
|
import { recordAttendanceSecurityProof } from './attendance-security.js';
|
||||||
import { evaluateClockInAttempt } from './clock-in-policy.js';
|
import { evaluateClockInAttempt } from './clock-in-policy.js';
|
||||||
import { enqueueHubManagerAlert } from './notification-outbox.js';
|
import { enqueueHubManagerAlert } from './notification-outbox.js';
|
||||||
|
import {
|
||||||
|
requireClientContext as requireActorClientContext,
|
||||||
|
requireStaffContext as requireActorStaffContext,
|
||||||
|
} from './actor-context.js';
|
||||||
|
|
||||||
function toIsoOrNull(value) {
|
function toIsoOrNull(value) {
|
||||||
return value ? new Date(value).toISOString() : null;
|
return value ? new Date(value).toISOString() : null;
|
||||||
@@ -68,6 +72,33 @@ async function ensureStaffNotBlockedByBusiness(client, { tenantId, businessId, s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assertTenantScope(context, tenantId) {
|
||||||
|
if (context.tenant.tenantId !== tenantId) {
|
||||||
|
throw new AppError('FORBIDDEN', 'Resource is outside actor tenant scope', 403, {
|
||||||
|
tenantId,
|
||||||
|
actorTenantId: context.tenant.tenantId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertBusinessScope(context, businessId) {
|
||||||
|
if (context.business && context.business.businessId !== businessId) {
|
||||||
|
throw new AppError('FORBIDDEN', 'Resource is outside actor business scope', 403, {
|
||||||
|
businessId,
|
||||||
|
actorBusinessId: context.business.businessId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertStaffScope(context, staffId) {
|
||||||
|
if (context.staff.staffId !== staffId) {
|
||||||
|
throw new AppError('FORBIDDEN', 'Resource is outside actor staff scope', 403, {
|
||||||
|
staffId,
|
||||||
|
actorStaffId: context.staff.staffId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function insertDomainEvent(client, {
|
async function insertDomainEvent(client, {
|
||||||
tenantId,
|
tenantId,
|
||||||
aggregateType,
|
aggregateType,
|
||||||
@@ -451,6 +482,9 @@ function buildOrderUpdateStatement(payload) {
|
|||||||
export async function createOrder(actor, payload) {
|
export async function createOrder(actor, payload) {
|
||||||
return withTransaction(async (client) => {
|
return withTransaction(async (client) => {
|
||||||
await ensureActorUser(client, actor);
|
await ensureActorUser(client, actor);
|
||||||
|
const actorContext = await requireActorClientContext(actor.uid);
|
||||||
|
assertTenantScope(actorContext, payload.tenantId);
|
||||||
|
assertBusinessScope(actorContext, payload.businessId);
|
||||||
await requireBusiness(client, payload.tenantId, payload.businessId);
|
await requireBusiness(client, payload.tenantId, payload.businessId);
|
||||||
if (payload.vendorId) {
|
if (payload.vendorId) {
|
||||||
await requireVendor(client, payload.tenantId, payload.vendorId);
|
await requireVendor(client, payload.tenantId, payload.vendorId);
|
||||||
@@ -620,8 +654,10 @@ export async function createOrder(actor, payload) {
|
|||||||
export async function acceptShift(actor, payload) {
|
export async function acceptShift(actor, payload) {
|
||||||
return withTransaction(async (client) => {
|
return withTransaction(async (client) => {
|
||||||
await ensureActorUser(client, actor);
|
await ensureActorUser(client, actor);
|
||||||
|
const actorContext = await requireActorStaffContext(actor.uid);
|
||||||
|
|
||||||
const shiftRole = await requireShiftRole(client, payload.shiftRoleId);
|
const shiftRole = await requireShiftRole(client, payload.shiftRoleId);
|
||||||
|
assertTenantScope(actorContext, shiftRole.tenant_id);
|
||||||
if (payload.shiftId && shiftRole.shift_id !== payload.shiftId) {
|
if (payload.shiftId && shiftRole.shift_id !== payload.shiftId) {
|
||||||
throw new AppError('VALIDATION_ERROR', 'shiftId does not match shiftRoleId', 400, {
|
throw new AppError('VALIDATION_ERROR', 'shiftId does not match shiftRoleId', 400, {
|
||||||
shiftId: payload.shiftId,
|
shiftId: payload.shiftId,
|
||||||
@@ -629,6 +665,13 @@ export async function acceptShift(actor, payload) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!actorContext.staff.workforceId || actorContext.staff.workforceId !== payload.workforceId) {
|
||||||
|
throw new AppError('FORBIDDEN', 'Staff can only accept shifts for their own workforce record', 403, {
|
||||||
|
workforceId: payload.workforceId,
|
||||||
|
actorWorkforceId: actorContext.staff.workforceId || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (shiftRole.assigned_count >= shiftRole.workers_needed) {
|
if (shiftRole.assigned_count >= shiftRole.workers_needed) {
|
||||||
const existingFilledAssignment = await findAssignmentForShiftRoleWorkforce(
|
const existingFilledAssignment = await findAssignmentForShiftRoleWorkforce(
|
||||||
client,
|
client,
|
||||||
@@ -736,7 +779,10 @@ export async function acceptShift(actor, payload) {
|
|||||||
export async function updateOrder(actor, payload) {
|
export async function updateOrder(actor, payload) {
|
||||||
return withTransaction(async (client) => {
|
return withTransaction(async (client) => {
|
||||||
await ensureActorUser(client, actor);
|
await ensureActorUser(client, actor);
|
||||||
|
const actorContext = await requireActorClientContext(actor.uid);
|
||||||
|
assertTenantScope(actorContext, payload.tenantId);
|
||||||
const existingOrder = await requireOrder(client, payload.tenantId, payload.orderId);
|
const existingOrder = await requireOrder(client, payload.tenantId, payload.orderId);
|
||||||
|
assertBusinessScope(actorContext, existingOrder.business_id);
|
||||||
|
|
||||||
if (Object.prototype.hasOwnProperty.call(payload, 'vendorId') && payload.vendorId) {
|
if (Object.prototype.hasOwnProperty.call(payload, 'vendorId') && payload.vendorId) {
|
||||||
await requireVendor(client, payload.tenantId, payload.vendorId);
|
await requireVendor(client, payload.tenantId, payload.vendorId);
|
||||||
@@ -787,7 +833,10 @@ export async function updateOrder(actor, payload) {
|
|||||||
export async function cancelOrder(actor, payload) {
|
export async function cancelOrder(actor, payload) {
|
||||||
return withTransaction(async (client) => {
|
return withTransaction(async (client) => {
|
||||||
await ensureActorUser(client, actor);
|
await ensureActorUser(client, actor);
|
||||||
|
const actorContext = await requireActorClientContext(actor.uid);
|
||||||
|
assertTenantScope(actorContext, payload.tenantId);
|
||||||
const order = await requireOrder(client, payload.tenantId, payload.orderId);
|
const order = await requireOrder(client, payload.tenantId, payload.orderId);
|
||||||
|
assertBusinessScope(actorContext, order.business_id);
|
||||||
|
|
||||||
if (order.status === 'CANCELLED') {
|
if (order.status === 'CANCELLED') {
|
||||||
return {
|
return {
|
||||||
@@ -910,7 +959,10 @@ export async function cancelOrder(actor, payload) {
|
|||||||
export async function changeShiftStatus(actor, payload) {
|
export async function changeShiftStatus(actor, payload) {
|
||||||
return withTransaction(async (client) => {
|
return withTransaction(async (client) => {
|
||||||
await ensureActorUser(client, actor);
|
await ensureActorUser(client, actor);
|
||||||
|
const actorContext = await requireActorClientContext(actor.uid);
|
||||||
|
assertTenantScope(actorContext, payload.tenantId);
|
||||||
const shift = await requireShift(client, payload.tenantId, payload.shiftId);
|
const shift = await requireShift(client, payload.tenantId, payload.shiftId);
|
||||||
|
assertBusinessScope(actorContext, shift.business_id);
|
||||||
|
|
||||||
if (payload.status === 'COMPLETED') {
|
if (payload.status === 'COMPLETED') {
|
||||||
const openSession = await client.query(
|
const openSession = await client.query(
|
||||||
@@ -999,7 +1051,10 @@ export async function changeShiftStatus(actor, payload) {
|
|||||||
export async function assignStaffToShift(actor, payload) {
|
export async function assignStaffToShift(actor, payload) {
|
||||||
return withTransaction(async (client) => {
|
return withTransaction(async (client) => {
|
||||||
await ensureActorUser(client, actor);
|
await ensureActorUser(client, actor);
|
||||||
|
const actorContext = await requireActorClientContext(actor.uid);
|
||||||
|
assertTenantScope(actorContext, payload.tenantId);
|
||||||
const shift = await requireShift(client, payload.tenantId, payload.shiftId);
|
const shift = await requireShift(client, payload.tenantId, payload.shiftId);
|
||||||
|
assertBusinessScope(actorContext, shift.business_id);
|
||||||
const shiftRole = await requireShiftRole(client, payload.shiftRoleId);
|
const shiftRole = await requireShiftRole(client, payload.shiftRoleId);
|
||||||
|
|
||||||
if (shiftRole.shift_id !== shift.id) {
|
if (shiftRole.shift_id !== shift.id) {
|
||||||
@@ -1120,7 +1175,10 @@ export async function assignStaffToShift(actor, payload) {
|
|||||||
async function createAttendanceEvent(actor, payload, eventType) {
|
async function createAttendanceEvent(actor, payload, eventType) {
|
||||||
return withTransaction(async (client) => {
|
return withTransaction(async (client) => {
|
||||||
await ensureActorUser(client, actor);
|
await ensureActorUser(client, actor);
|
||||||
|
const actorContext = await requireActorStaffContext(actor.uid);
|
||||||
const assignment = await requireAssignment(client, payload.assignmentId);
|
const assignment = await requireAssignment(client, payload.assignmentId);
|
||||||
|
assertTenantScope(actorContext, assignment.tenant_id);
|
||||||
|
assertStaffScope(actorContext, assignment.staff_id);
|
||||||
const capturedAt = toIsoOrNull(payload.capturedAt) || new Date().toISOString();
|
const capturedAt = toIsoOrNull(payload.capturedAt) || new Date().toISOString();
|
||||||
let securityProof = null;
|
let securityProof = null;
|
||||||
|
|
||||||
@@ -1553,6 +1611,9 @@ export async function clockOut(actor, payload) {
|
|||||||
export async function addFavoriteStaff(actor, payload) {
|
export async function addFavoriteStaff(actor, payload) {
|
||||||
return withTransaction(async (client) => {
|
return withTransaction(async (client) => {
|
||||||
await ensureActorUser(client, actor);
|
await ensureActorUser(client, actor);
|
||||||
|
const actorContext = await requireActorClientContext(actor.uid);
|
||||||
|
assertTenantScope(actorContext, payload.tenantId);
|
||||||
|
assertBusinessScope(actorContext, payload.businessId);
|
||||||
await requireBusiness(client, payload.tenantId, payload.businessId);
|
await requireBusiness(client, payload.tenantId, payload.businessId);
|
||||||
|
|
||||||
const staffResult = await client.query(
|
const staffResult = await client.query(
|
||||||
@@ -1605,6 +1666,9 @@ export async function addFavoriteStaff(actor, payload) {
|
|||||||
export async function removeFavoriteStaff(actor, payload) {
|
export async function removeFavoriteStaff(actor, payload) {
|
||||||
return withTransaction(async (client) => {
|
return withTransaction(async (client) => {
|
||||||
await ensureActorUser(client, actor);
|
await ensureActorUser(client, actor);
|
||||||
|
const actorContext = await requireActorClientContext(actor.uid);
|
||||||
|
assertTenantScope(actorContext, payload.tenantId);
|
||||||
|
assertBusinessScope(actorContext, payload.businessId);
|
||||||
const deleted = await client.query(
|
const deleted = await client.query(
|
||||||
`
|
`
|
||||||
DELETE FROM staff_favorites
|
DELETE FROM staff_favorites
|
||||||
@@ -1640,7 +1704,11 @@ export async function removeFavoriteStaff(actor, payload) {
|
|||||||
export async function createStaffReview(actor, payload) {
|
export async function createStaffReview(actor, payload) {
|
||||||
return withTransaction(async (client) => {
|
return withTransaction(async (client) => {
|
||||||
await ensureActorUser(client, actor);
|
await ensureActorUser(client, actor);
|
||||||
|
const actorContext = await requireActorClientContext(actor.uid);
|
||||||
|
assertTenantScope(actorContext, payload.tenantId);
|
||||||
|
assertBusinessScope(actorContext, payload.businessId);
|
||||||
const assignment = await requireAssignment(client, payload.assignmentId);
|
const assignment = await requireAssignment(client, payload.assignmentId);
|
||||||
|
assertBusinessScope(actorContext, assignment.business_id);
|
||||||
if (assignment.business_id !== payload.businessId || assignment.staff_id !== payload.staffId) {
|
if (assignment.business_id !== payload.businessId || assignment.staff_id !== payload.staffId) {
|
||||||
throw new AppError('VALIDATION_ERROR', 'Assignment does not match business/staff review target', 400, {
|
throw new AppError('VALIDATION_ERROR', 'Assignment does not match business/staff review target', 400, {
|
||||||
assignmentId: payload.assignmentId,
|
assignmentId: payload.assignmentId,
|
||||||
|
|||||||
@@ -2883,6 +2883,299 @@ export async function applyForShift(actor, payload) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function bookOrder(actor, payload) {
|
||||||
|
const context = await requireStaffContext(actor.uid);
|
||||||
|
return withTransaction(async (client) => {
|
||||||
|
await ensureActorUser(client, actor);
|
||||||
|
const staff = await requireStaffByActor(client, context.tenant.tenantId, actor.uid);
|
||||||
|
|
||||||
|
if (!staff.workforce_id) {
|
||||||
|
throw new AppError('UNPROCESSABLE_ENTITY', 'Staff must have an active workforce profile before booking an order', 422, {
|
||||||
|
orderId: payload.orderId,
|
||||||
|
staffId: staff.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleLookup = await client.query(
|
||||||
|
`
|
||||||
|
SELECT id, code, name
|
||||||
|
FROM roles_catalog
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
AND id = $2
|
||||||
|
AND status = 'ACTIVE'
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
[context.tenant.tenantId, payload.roleId]
|
||||||
|
);
|
||||||
|
if (roleLookup.rowCount === 0) {
|
||||||
|
throw new AppError('VALIDATION_ERROR', 'roleId must reference an active role in the tenant catalog', 400, {
|
||||||
|
roleId: payload.roleId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const selectedRole = roleLookup.rows[0];
|
||||||
|
|
||||||
|
const orderLookup = await client.query(
|
||||||
|
`
|
||||||
|
SELECT id, business_id, metadata
|
||||||
|
FROM orders
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
AND id = $2
|
||||||
|
LIMIT 1
|
||||||
|
FOR UPDATE
|
||||||
|
`,
|
||||||
|
[context.tenant.tenantId, payload.orderId]
|
||||||
|
);
|
||||||
|
if (orderLookup.rowCount === 0) {
|
||||||
|
throw new AppError('NOT_FOUND', 'Order not found', 404, {
|
||||||
|
orderId: payload.orderId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingOrderParticipation = await client.query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
s.id AS shift_id,
|
||||||
|
sr.id AS shift_role_id,
|
||||||
|
a.id AS assignment_id,
|
||||||
|
app.id AS application_id
|
||||||
|
FROM shifts s
|
||||||
|
JOIN shift_roles sr ON sr.shift_id = s.id
|
||||||
|
LEFT JOIN assignments a
|
||||||
|
ON a.shift_role_id = sr.id
|
||||||
|
AND a.staff_id = $3
|
||||||
|
AND a.status IN ('ASSIGNED', 'ACCEPTED', 'SWAP_REQUESTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED')
|
||||||
|
LEFT JOIN applications app
|
||||||
|
ON app.shift_role_id = sr.id
|
||||||
|
AND app.staff_id = $3
|
||||||
|
AND app.status IN ('PENDING', 'CONFIRMED', 'CHECKED_IN', 'COMPLETED')
|
||||||
|
WHERE s.tenant_id = $1
|
||||||
|
AND s.order_id = $2
|
||||||
|
AND s.starts_at > NOW()
|
||||||
|
AND (a.id IS NOT NULL OR app.id IS NOT NULL)
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
[context.tenant.tenantId, payload.orderId, staff.id]
|
||||||
|
);
|
||||||
|
if (existingOrderParticipation.rowCount > 0) {
|
||||||
|
throw new AppError('CONFLICT', 'Staff already has participation on this order', 409, {
|
||||||
|
orderId: payload.orderId,
|
||||||
|
shiftId: existingOrderParticipation.rows[0].shift_id,
|
||||||
|
shiftRoleId: existingOrderParticipation.rows[0].shift_role_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidateRoles = await client.query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
s.id AS shift_id,
|
||||||
|
s.order_id,
|
||||||
|
s.business_id,
|
||||||
|
s.vendor_id,
|
||||||
|
s.clock_point_id,
|
||||||
|
s.status AS shift_status,
|
||||||
|
s.starts_at,
|
||||||
|
s.ends_at,
|
||||||
|
COALESCE(s.timezone, 'UTC') AS timezone,
|
||||||
|
to_char(s.starts_at AT TIME ZONE COALESCE(s.timezone, 'UTC'), 'YYYY-MM-DD') AS local_date,
|
||||||
|
to_char(s.starts_at AT TIME ZONE COALESCE(s.timezone, 'UTC'), 'HH24:MI') AS local_start_time,
|
||||||
|
to_char(s.ends_at AT TIME ZONE COALESCE(s.timezone, 'UTC'), 'HH24:MI') AS local_end_time,
|
||||||
|
sr.id AS shift_role_id,
|
||||||
|
COALESCE(sr.role_id, rc.id) AS catalog_role_id,
|
||||||
|
COALESCE(sr.role_code, rc.code) AS role_code,
|
||||||
|
COALESCE(sr.role_name, rc.name) AS role_name,
|
||||||
|
sr.workers_needed,
|
||||||
|
sr.assigned_count,
|
||||||
|
COALESCE((sr.metadata->>'instantBook')::boolean, FALSE) AS instant_book
|
||||||
|
FROM shifts s
|
||||||
|
JOIN shift_roles sr ON sr.shift_id = s.id
|
||||||
|
LEFT JOIN roles_catalog rc
|
||||||
|
ON rc.tenant_id = s.tenant_id
|
||||||
|
AND (rc.id = sr.role_id OR (sr.role_id IS NULL AND rc.code = sr.role_code))
|
||||||
|
WHERE s.tenant_id = $1
|
||||||
|
AND s.order_id = $2
|
||||||
|
AND s.starts_at > NOW()
|
||||||
|
AND COALESCE(sr.role_id, rc.id) = $3
|
||||||
|
ORDER BY s.starts_at ASC, sr.created_at ASC
|
||||||
|
FOR UPDATE OF s, sr
|
||||||
|
`,
|
||||||
|
[context.tenant.tenantId, payload.orderId, payload.roleId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (candidateRoles.rowCount === 0) {
|
||||||
|
throw new AppError('UNPROCESSABLE_ENTITY', 'Order has no future shifts available for this role', 422, {
|
||||||
|
orderId: payload.orderId,
|
||||||
|
roleId: payload.roleId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockedOrUnavailable = candidateRoles.rows.find((row) => row.shift_status !== 'OPEN' || row.assigned_count >= row.workers_needed);
|
||||||
|
if (blockedOrUnavailable) {
|
||||||
|
throw new AppError('UNPROCESSABLE_ENTITY', 'Order is no longer fully bookable', 422, {
|
||||||
|
orderId: payload.orderId,
|
||||||
|
roleId: payload.roleId,
|
||||||
|
shiftId: blockedOrUnavailable.shift_id,
|
||||||
|
shiftRoleId: blockedOrUnavailable.shift_role_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureStaffNotBlockedByBusiness(client, {
|
||||||
|
tenantId: context.tenant.tenantId,
|
||||||
|
businessId: candidateRoles.rows[0].business_id,
|
||||||
|
staffId: staff.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const bookingId = crypto.randomUUID();
|
||||||
|
const assignedShifts = [];
|
||||||
|
|
||||||
|
for (const row of candidateRoles.rows) {
|
||||||
|
const dispatchMembership = await loadDispatchMembership(client, {
|
||||||
|
tenantId: context.tenant.tenantId,
|
||||||
|
businessId: row.business_id,
|
||||||
|
hubId: row.clock_point_id,
|
||||||
|
staffId: staff.id,
|
||||||
|
});
|
||||||
|
const instantBook = Boolean(row.instant_book);
|
||||||
|
|
||||||
|
const applicationResult = await client.query(
|
||||||
|
`
|
||||||
|
INSERT INTO applications (
|
||||||
|
tenant_id,
|
||||||
|
shift_id,
|
||||||
|
shift_role_id,
|
||||||
|
staff_id,
|
||||||
|
status,
|
||||||
|
origin,
|
||||||
|
metadata
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, 'STAFF', $6::jsonb)
|
||||||
|
ON CONFLICT (shift_role_id, staff_id) DO NOTHING
|
||||||
|
RETURNING id, status
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
context.tenant.tenantId,
|
||||||
|
row.shift_id,
|
||||||
|
row.shift_role_id,
|
||||||
|
staff.id,
|
||||||
|
instantBook ? 'CONFIRMED' : 'PENDING',
|
||||||
|
JSON.stringify({
|
||||||
|
bookingId,
|
||||||
|
bookedBy: actor.uid,
|
||||||
|
source: 'staff-order-booking',
|
||||||
|
orderId: payload.orderId,
|
||||||
|
catalogRoleId: payload.roleId,
|
||||||
|
roleCode: selectedRole.code,
|
||||||
|
dispatchTeamType: dispatchMembership.teamType,
|
||||||
|
dispatchPriority: dispatchMembership.priority,
|
||||||
|
dispatchTeamMembershipId: dispatchMembership.membershipId,
|
||||||
|
dispatchTeamScopeHubId: dispatchMembership.scopedHubId,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
if (applicationResult.rowCount === 0) {
|
||||||
|
throw new AppError('CONFLICT', 'Order booking conflicted with an existing application', 409, {
|
||||||
|
orderId: payload.orderId,
|
||||||
|
shiftId: row.shift_id,
|
||||||
|
shiftRoleId: row.shift_role_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignmentResult = await client.query(
|
||||||
|
`
|
||||||
|
INSERT INTO assignments (
|
||||||
|
tenant_id,
|
||||||
|
business_id,
|
||||||
|
vendor_id,
|
||||||
|
shift_id,
|
||||||
|
shift_role_id,
|
||||||
|
workforce_id,
|
||||||
|
staff_id,
|
||||||
|
application_id,
|
||||||
|
status,
|
||||||
|
assigned_at,
|
||||||
|
accepted_at,
|
||||||
|
metadata
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), CASE WHEN $10::boolean THEN NOW() ELSE NULL END, $11::jsonb)
|
||||||
|
ON CONFLICT (shift_role_id, workforce_id) DO NOTHING
|
||||||
|
RETURNING id, status
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
context.tenant.tenantId,
|
||||||
|
row.business_id,
|
||||||
|
row.vendor_id,
|
||||||
|
row.shift_id,
|
||||||
|
row.shift_role_id,
|
||||||
|
staff.workforce_id,
|
||||||
|
staff.id,
|
||||||
|
applicationResult.rows[0].id,
|
||||||
|
instantBook ? 'ACCEPTED' : 'ASSIGNED',
|
||||||
|
instantBook,
|
||||||
|
JSON.stringify({
|
||||||
|
bookingId,
|
||||||
|
bookedBy: actor.uid,
|
||||||
|
source: 'staff-order-booking',
|
||||||
|
orderId: payload.orderId,
|
||||||
|
catalogRoleId: payload.roleId,
|
||||||
|
roleCode: selectedRole.code,
|
||||||
|
pendingApproval: !instantBook,
|
||||||
|
dispatchTeamType: dispatchMembership.teamType,
|
||||||
|
dispatchPriority: dispatchMembership.priority,
|
||||||
|
dispatchTeamMembershipId: dispatchMembership.membershipId,
|
||||||
|
dispatchTeamScopeHubId: dispatchMembership.scopedHubId,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
if (assignmentResult.rowCount === 0) {
|
||||||
|
throw new AppError('CONFLICT', 'Order booking conflicted with an existing assignment', 409, {
|
||||||
|
orderId: payload.orderId,
|
||||||
|
shiftId: row.shift_id,
|
||||||
|
shiftRoleId: row.shift_role_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshShiftRoleCounts(client, row.shift_role_id);
|
||||||
|
await refreshShiftCounts(client, row.shift_id);
|
||||||
|
|
||||||
|
assignedShifts.push({
|
||||||
|
shiftId: row.shift_id,
|
||||||
|
date: row.local_date,
|
||||||
|
startsAt: row.starts_at,
|
||||||
|
endsAt: row.ends_at,
|
||||||
|
startTime: row.local_start_time,
|
||||||
|
endTime: row.local_end_time,
|
||||||
|
timezone: row.timezone,
|
||||||
|
assignmentId: assignmentResult.rows[0].id,
|
||||||
|
assignmentStatus: assignmentResult.rows[0].status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await insertDomainEvent(client, {
|
||||||
|
tenantId: context.tenant.tenantId,
|
||||||
|
aggregateType: 'order',
|
||||||
|
aggregateId: payload.orderId,
|
||||||
|
eventType: candidateRoles.rows.every((row) => row.instant_book) ? 'STAFF_ORDER_BOOKED_CONFIRMED' : 'STAFF_ORDER_BOOKED_PENDING',
|
||||||
|
actorUserId: actor.uid,
|
||||||
|
payload: {
|
||||||
|
bookingId,
|
||||||
|
roleId: payload.roleId,
|
||||||
|
roleCode: selectedRole.code,
|
||||||
|
assignedShiftCount: assignedShifts.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
bookingId,
|
||||||
|
orderId: payload.orderId,
|
||||||
|
roleId: payload.roleId,
|
||||||
|
roleCode: selectedRole.code,
|
||||||
|
roleName: selectedRole.name,
|
||||||
|
assignedShiftCount: assignedShifts.length,
|
||||||
|
status: candidateRoles.rows.every((row) => row.instant_book) ? 'CONFIRMED' : 'PENDING',
|
||||||
|
assignedShifts,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function acceptPendingShift(actor, payload) {
|
export async function acceptPendingShift(actor, payload) {
|
||||||
const context = await requireStaffContext(actor.uid);
|
const context = await requireStaffContext(actor.uid);
|
||||||
return withTransaction(async (client) => {
|
return withTransaction(async (client) => {
|
||||||
|
|||||||
@@ -1,5 +1,125 @@
|
|||||||
export function can(action, resource, actor) {
|
import { loadActorContext } from './actor-context.js';
|
||||||
void action;
|
|
||||||
void resource;
|
const TENANT_ADMIN_ROLES = new Set(['OWNER', 'ADMIN']);
|
||||||
return Boolean(actor?.uid);
|
|
||||||
|
function normalize(value) {
|
||||||
|
return `${value || ''}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestField(req, field) {
|
||||||
|
return normalize(
|
||||||
|
req?.params?.[field]
|
||||||
|
?? req?.body?.[field]
|
||||||
|
?? req?.query?.[field]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTenantAdmin(context) {
|
||||||
|
return TENANT_ADMIN_ROLES.has(normalize(context?.tenant?.role).toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasTenantScope(context) {
|
||||||
|
return Boolean(context?.user && context?.tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasClientScope(context) {
|
||||||
|
return hasTenantScope(context) && Boolean(context?.business || isTenantAdmin(context));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasStaffScope(context) {
|
||||||
|
return hasTenantScope(context) && Boolean(context?.staff);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredScopeFor(action) {
|
||||||
|
if (action === 'notifications.device.write') {
|
||||||
|
return 'tenant';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
action === 'orders.create'
|
||||||
|
|| action === 'orders.update'
|
||||||
|
|| action === 'orders.cancel'
|
||||||
|
|| action === 'shifts.change-status'
|
||||||
|
|| action === 'shifts.assign-staff'
|
||||||
|
|| action === 'business.favorite-staff'
|
||||||
|
|| action === 'business.unfavorite-staff'
|
||||||
|
|| action === 'assignments.review-staff'
|
||||||
|
|| action.startsWith('client.')
|
||||||
|
|| action.startsWith('billing.')
|
||||||
|
|| action.startsWith('coverage.')
|
||||||
|
|| action.startsWith('hubs.')
|
||||||
|
|| action.startsWith('vendors.')
|
||||||
|
|| action.startsWith('reports.')
|
||||||
|
) {
|
||||||
|
return 'client';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
action === 'shifts.accept'
|
||||||
|
|| action === 'attendance.clock-in'
|
||||||
|
|| action === 'attendance.clock-out'
|
||||||
|
|| action === 'attendance.location-stream.write'
|
||||||
|
|| action.startsWith('staff.')
|
||||||
|
|| action.startsWith('payments.')
|
||||||
|
) {
|
||||||
|
return 'staff';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'deny';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveActorContext(actor) {
|
||||||
|
if (!actor?.uid) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (actor.policyContext) {
|
||||||
|
return actor.policyContext;
|
||||||
|
}
|
||||||
|
const context = await loadActorContext(actor.uid);
|
||||||
|
actor.policyContext = context;
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestScopeMatches(req, context, requiredScope) {
|
||||||
|
const tenantId = requestField(req, 'tenantId');
|
||||||
|
if (tenantId && context?.tenant?.tenantId !== '*' && context?.tenant?.tenantId !== tenantId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const businessId = requestField(req, 'businessId');
|
||||||
|
if (
|
||||||
|
requiredScope === 'client'
|
||||||
|
&& businessId
|
||||||
|
&& context?.business?.businessId
|
||||||
|
&& context.business.businessId !== '*'
|
||||||
|
&& context.business.businessId !== businessId
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function can(action, resource, actor, req) {
|
||||||
|
void resource;
|
||||||
|
const context = await resolveActorContext(actor);
|
||||||
|
const requiredScope = requiredScopeFor(action);
|
||||||
|
|
||||||
|
if (requiredScope === 'deny' || !context?.user) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requiredScope === 'tenant') {
|
||||||
|
return hasTenantScope(context) && requestScopeMatches(req, context, requiredScope);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requiredScope === 'client') {
|
||||||
|
return hasClientScope(context) && requestScopeMatches(req, context, requiredScope);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requiredScope === 'staff') {
|
||||||
|
return hasStaffScope(context) && requestScopeMatches(req, context, requiredScope);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import pino from 'pino';
|
import pino from 'pino';
|
||||||
import pinoHttp from 'pino-http';
|
import pinoHttp from 'pino-http';
|
||||||
|
import { assertSafeWorkerRuntimeConfig } from './lib/runtime-safety.js';
|
||||||
|
|
||||||
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
|
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
|
||||||
|
|
||||||
export function createWorkerApp({ dispatch = async () => ({}) } = {}) {
|
export function createWorkerApp({ dispatch = async () => ({}) } = {}) {
|
||||||
|
assertSafeWorkerRuntimeConfig();
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ function validOrderCreatePayload() {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
process.env.IDEMPOTENCY_STORE = 'memory';
|
process.env.IDEMPOTENCY_STORE = 'memory';
|
||||||
|
delete process.env.AUTH_BYPASS_CONTEXT;
|
||||||
delete process.env.IDEMPOTENCY_DATABASE_URL;
|
delete process.env.IDEMPOTENCY_DATABASE_URL;
|
||||||
delete process.env.DATABASE_URL;
|
delete process.env.DATABASE_URL;
|
||||||
__resetIdempotencyStoreForTests();
|
__resetIdempotencyStoreForTests();
|
||||||
@@ -63,6 +64,16 @@ test('GET /readyz reports database not configured when no database env is presen
|
|||||||
assert.equal(res.body.status, 'DATABASE_NOT_CONFIGURED');
|
assert.equal(res.body.status, 'DATABASE_NOT_CONFIGURED');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('createApp fails fast in protected env when auth bypass is enabled', async () => {
|
||||||
|
process.env.APP_ENV = 'staging';
|
||||||
|
process.env.AUTH_BYPASS = 'true';
|
||||||
|
|
||||||
|
assert.throws(() => createApp(), /AUTH_BYPASS must be disabled/);
|
||||||
|
|
||||||
|
delete process.env.APP_ENV;
|
||||||
|
process.env.AUTH_BYPASS = 'true';
|
||||||
|
});
|
||||||
|
|
||||||
test('command route requires idempotency key', async () => {
|
test('command route requires idempotency key', async () => {
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
@@ -116,3 +127,36 @@ test('command route is idempotent by key and only executes handler once', async
|
|||||||
assert.equal(first.body.idempotencyKey, 'abc-123');
|
assert.equal(first.body.idempotencyKey, 'abc-123');
|
||||||
assert.equal(second.body.idempotencyKey, 'abc-123');
|
assert.equal(second.body.idempotencyKey, 'abc-123');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('client command routes deny mismatched business scope before handler execution', async () => {
|
||||||
|
process.env.AUTH_BYPASS_CONTEXT = JSON.stringify({
|
||||||
|
user: { userId: 'test-user' },
|
||||||
|
tenant: { tenantId, role: 'MANAGER' },
|
||||||
|
business: { businessId: '99999999-9999-4999-8999-999999999999' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = createApp({
|
||||||
|
commandHandlers: {
|
||||||
|
createOrder: async () => assert.fail('createOrder should not be called'),
|
||||||
|
acceptShift: async () => assert.fail('acceptShift should not be called'),
|
||||||
|
clockIn: async () => assert.fail('clockIn should not be called'),
|
||||||
|
clockOut: async () => assert.fail('clockOut should not be called'),
|
||||||
|
addFavoriteStaff: async () => assert.fail('addFavoriteStaff should not be called'),
|
||||||
|
removeFavoriteStaff: async () => assert.fail('removeFavoriteStaff should not be called'),
|
||||||
|
createStaffReview: async () => assert.fail('createStaffReview should not be called'),
|
||||||
|
updateOrder: async () => assert.fail('updateOrder should not be called'),
|
||||||
|
cancelOrder: async () => assert.fail('cancelOrder should not be called'),
|
||||||
|
changeShiftStatus: async () => assert.fail('changeShiftStatus should not be called'),
|
||||||
|
assignStaffToShift: async () => assert.fail('assignStaffToShift should not be called'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/commands/orders/create')
|
||||||
|
.set('Authorization', 'Bearer test-token')
|
||||||
|
.set('Idempotency-Key', 'scope-mismatch')
|
||||||
|
.send(validOrderCreatePayload());
|
||||||
|
|
||||||
|
assert.equal(res.status, 403);
|
||||||
|
assert.equal(res.body.code, 'FORBIDDEN');
|
||||||
|
});
|
||||||
|
|||||||
@@ -65,6 +65,14 @@ function createMobileHandlers() {
|
|||||||
invoiceId: payload.invoiceId,
|
invoiceId: payload.invoiceId,
|
||||||
status: 'APPROVED',
|
status: 'APPROVED',
|
||||||
}),
|
}),
|
||||||
|
bookOrder: async (_actor, payload) => ({
|
||||||
|
bookingId: 'booking-1',
|
||||||
|
orderId: payload.orderId,
|
||||||
|
roleId: payload.roleId,
|
||||||
|
assignedShiftCount: 3,
|
||||||
|
status: 'PENDING',
|
||||||
|
assignedShifts: [],
|
||||||
|
}),
|
||||||
registerClientPushToken: async (_actor, payload) => ({
|
registerClientPushToken: async (_actor, payload) => ({
|
||||||
tokenId: 'push-token-client-1',
|
tokenId: 'push-token-client-1',
|
||||||
platform: payload.platform,
|
platform: payload.platform,
|
||||||
@@ -410,6 +418,23 @@ test('POST /commands/staff/shifts/:shiftId/submit-for-approval injects shift id
|
|||||||
assert.equal(res.body.submitted, true);
|
assert.equal(res.body.submitted, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('POST /commands/staff/orders/:orderId/book injects order id from params', async () => {
|
||||||
|
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/commands/staff/orders/88888888-8888-4888-8888-888888888888/book')
|
||||||
|
.set('Authorization', 'Bearer test-token')
|
||||||
|
.set('Idempotency-Key', 'staff-order-book-1')
|
||||||
|
.send({
|
||||||
|
roleId: '99999999-9999-4999-8999-999999999999',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.body.orderId, '88888888-8888-4888-8888-888888888888');
|
||||||
|
assert.equal(res.body.roleId, '99999999-9999-4999-8999-999999999999');
|
||||||
|
assert.equal(res.body.assignedShiftCount, 3);
|
||||||
|
assert.equal(res.body.status, 'PENDING');
|
||||||
|
});
|
||||||
|
|
||||||
test('POST /commands/client/coverage/swap-requests/:swapRequestId/resolve injects swap request id from params', async () => {
|
test('POST /commands/client/coverage/swap-requests/:swapRequestId/resolve injects swap request id from params', async () => {
|
||||||
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
|
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
|
|||||||
@@ -12,6 +12,16 @@ test('GET /readyz returns healthy response', async () => {
|
|||||||
assert.equal(res.body.service, 'notification-worker-v2');
|
assert.equal(res.body.service, 'notification-worker-v2');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('createWorkerApp fails fast in protected env when push delivery is not live', async () => {
|
||||||
|
process.env.APP_ENV = 'staging';
|
||||||
|
process.env.PUSH_DELIVERY_MODE = 'log-only';
|
||||||
|
|
||||||
|
assert.throws(() => createWorkerApp(), /PUSH_DELIVERY_MODE must be live/);
|
||||||
|
|
||||||
|
delete process.env.APP_ENV;
|
||||||
|
delete process.env.PUSH_DELIVERY_MODE;
|
||||||
|
});
|
||||||
|
|
||||||
test('POST /tasks/dispatch-notifications returns dispatch summary', async () => {
|
test('POST /tasks/dispatch-notifications returns dispatch summary', async () => {
|
||||||
const app = createWorkerApp({
|
const app = createWorkerApp({
|
||||||
dispatch: async () => ({
|
dispatch: async () => ({
|
||||||
|
|||||||
86
backend/command-api/test/policy.test.js
Normal file
86
backend/command-api/test/policy.test.js
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { can } from '../src/services/policy.js';
|
||||||
|
|
||||||
|
test('client actions require business scope and matching business id', async () => {
|
||||||
|
const allowed = await can(
|
||||||
|
'orders.create',
|
||||||
|
'order',
|
||||||
|
{
|
||||||
|
uid: 'user-1',
|
||||||
|
policyContext: {
|
||||||
|
user: { userId: 'user-1' },
|
||||||
|
tenant: { tenantId: 'tenant-1', role: 'MANAGER' },
|
||||||
|
business: { businessId: 'business-1' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ body: { tenantId: 'tenant-1', businessId: 'business-1' } }
|
||||||
|
);
|
||||||
|
|
||||||
|
const denied = await can(
|
||||||
|
'orders.create',
|
||||||
|
'order',
|
||||||
|
{
|
||||||
|
uid: 'user-1',
|
||||||
|
policyContext: {
|
||||||
|
user: { userId: 'user-1' },
|
||||||
|
tenant: { tenantId: 'tenant-1', role: 'MANAGER' },
|
||||||
|
business: { businessId: 'business-1' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ body: { tenantId: 'tenant-1', businessId: 'business-2' } }
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(allowed, true);
|
||||||
|
assert.equal(denied, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('staff actions require staff scope', async () => {
|
||||||
|
const allowed = await can(
|
||||||
|
'shifts.accept',
|
||||||
|
'shift',
|
||||||
|
{
|
||||||
|
uid: 'user-1',
|
||||||
|
policyContext: {
|
||||||
|
user: { userId: 'user-1' },
|
||||||
|
tenant: { tenantId: 'tenant-1' },
|
||||||
|
staff: { staffId: 'staff-1', workforceId: 'workforce-1' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ body: { tenantId: 'tenant-1' } }
|
||||||
|
);
|
||||||
|
|
||||||
|
const denied = await can(
|
||||||
|
'shifts.accept',
|
||||||
|
'shift',
|
||||||
|
{
|
||||||
|
uid: 'user-1',
|
||||||
|
policyContext: {
|
||||||
|
user: { userId: 'user-1' },
|
||||||
|
tenant: { tenantId: 'tenant-1' },
|
||||||
|
business: { businessId: 'business-1' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ body: { tenantId: 'tenant-1' } }
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(allowed, true);
|
||||||
|
assert.equal(denied, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('notifications.device.write allows tenant-scoped actor', async () => {
|
||||||
|
const allowed = await can(
|
||||||
|
'notifications.device.write',
|
||||||
|
'device',
|
||||||
|
{
|
||||||
|
uid: 'user-1',
|
||||||
|
policyContext: {
|
||||||
|
user: { userId: 'user-1' },
|
||||||
|
tenant: { tenantId: 'tenant-1' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ body: { tenantId: 'tenant-1' } }
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(allowed, true);
|
||||||
|
});
|
||||||
@@ -5,10 +5,12 @@ import { requestContext } from './middleware/request-context.js';
|
|||||||
import { errorHandler, notFoundHandler } from './middleware/error-handler.js';
|
import { errorHandler, notFoundHandler } from './middleware/error-handler.js';
|
||||||
import { healthRouter } from './routes/health.js';
|
import { healthRouter } from './routes/health.js';
|
||||||
import { createCoreRouter, createLegacyCoreRouter } from './routes/core.js';
|
import { createCoreRouter, createLegacyCoreRouter } from './routes/core.js';
|
||||||
|
import { assertSafeRuntimeConfig } from './lib/runtime-safety.js';
|
||||||
|
|
||||||
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
|
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
|
||||||
|
|
||||||
export function createApp() {
|
export function createApp() {
|
||||||
|
assertSafeRuntimeConfig();
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
app.use(requestContext);
|
app.use(requestContext);
|
||||||
|
|||||||
45
backend/core-api/src/lib/runtime-safety.js
Normal file
45
backend/core-api/src/lib/runtime-safety.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
function runtimeEnvName() {
|
||||||
|
return `${process.env.APP_ENV || process.env.NODE_ENV || ''}`.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProtectedEnv() {
|
||||||
|
return ['staging', 'prod', 'production'].includes(runtimeEnvName());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertSafeRuntimeConfig() {
|
||||||
|
if (!isProtectedEnv()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
if (process.env.AUTH_BYPASS === 'true') {
|
||||||
|
errors.push('AUTH_BYPASS must be disabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.UPLOAD_MOCK !== 'false') {
|
||||||
|
errors.push('UPLOAD_MOCK must be false');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.SIGNED_URL_MOCK !== 'false') {
|
||||||
|
errors.push('SIGNED_URL_MOCK must be false');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.LLM_MOCK !== 'false') {
|
||||||
|
errors.push('LLM_MOCK must be false');
|
||||||
|
}
|
||||||
|
|
||||||
|
const verificationStore = `${process.env.VERIFICATION_STORE || 'sql'}`.trim().toLowerCase();
|
||||||
|
if (verificationStore !== 'sql') {
|
||||||
|
errors.push('VERIFICATION_STORE must be sql');
|
||||||
|
}
|
||||||
|
|
||||||
|
const verificationAccessMode = `${process.env.VERIFICATION_ACCESS_MODE || 'tenant'}`.trim().toLowerCase();
|
||||||
|
if (verificationAccessMode === 'authenticated') {
|
||||||
|
errors.push('VERIFICATION_ACCESS_MODE must not be authenticated');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
throw new Error(`Unsafe core-api runtime config for ${runtimeEnvName()}: ${errors.join('; ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,28 @@ function getBearerToken(header) {
|
|||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildBypassActor() {
|
||||||
|
let policyContext = {
|
||||||
|
user: { userId: 'test-user' },
|
||||||
|
tenant: { tenantId: '*' },
|
||||||
|
staff: { staffId: '*', workforceId: '*' },
|
||||||
|
};
|
||||||
|
|
||||||
|
if (process.env.AUTH_BYPASS_CONTEXT) {
|
||||||
|
try {
|
||||||
|
policyContext = JSON.parse(process.env.AUTH_BYPASS_CONTEXT);
|
||||||
|
} catch (_error) {
|
||||||
|
policyContext = {
|
||||||
|
user: { userId: 'test-user' },
|
||||||
|
tenant: { tenantId: '*' },
|
||||||
|
staff: { staffId: '*', workforceId: '*' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { uid: 'test-user', email: 'test@krow.local', role: 'TEST', policyContext };
|
||||||
|
}
|
||||||
|
|
||||||
export async function requireAuth(req, _res, next) {
|
export async function requireAuth(req, _res, next) {
|
||||||
try {
|
try {
|
||||||
const token = getBearerToken(req.get('Authorization'));
|
const token = getBearerToken(req.get('Authorization'));
|
||||||
@@ -17,7 +39,7 @@ export async function requireAuth(req, _res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (process.env.AUTH_BYPASS === 'true') {
|
if (process.env.AUTH_BYPASS === 'true') {
|
||||||
req.actor = { uid: 'test-user', email: 'test@krow.local', role: 'TEST' };
|
req.actor = buildBypassActor();
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,10 +58,14 @@ export async function requireAuth(req, _res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function requirePolicy(action, resource) {
|
export function requirePolicy(action, resource) {
|
||||||
return (req, _res, next) => {
|
return async (req, _res, next) => {
|
||||||
if (!can(action, resource, req.actor)) {
|
try {
|
||||||
|
if (!(await can(action, resource, req.actor, req))) {
|
||||||
return next(new AppError('FORBIDDEN', 'Not allowed to perform this action', 403));
|
return next(new AppError('FORBIDDEN', 'Not allowed to perform this action', 403));
|
||||||
}
|
}
|
||||||
return next();
|
return next();
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,46 @@
|
|||||||
export function can(action, resource, actor) {
|
import { loadActorContext } from './actor-context.js';
|
||||||
void action;
|
|
||||||
void resource;
|
function normalize(value) {
|
||||||
return Boolean(actor?.uid);
|
return `${value || ''}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestField(req, field) {
|
||||||
|
return normalize(
|
||||||
|
req?.params?.[field]
|
||||||
|
?? req?.body?.[field]
|
||||||
|
?? req?.query?.[field]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveActorContext(actor) {
|
||||||
|
if (!actor?.uid) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (actor.policyContext) {
|
||||||
|
return actor.policyContext;
|
||||||
|
}
|
||||||
|
const context = await loadActorContext(actor.uid);
|
||||||
|
actor.policyContext = context;
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function can(action, resource, actor, req) {
|
||||||
|
void resource;
|
||||||
|
if (!action.startsWith('core.')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = await resolveActorContext(actor);
|
||||||
|
if (!context?.user || !context?.tenant) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenantId = requestField(req, 'tenantId');
|
||||||
|
if (!tenantId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (context.tenant.tenantId === '*') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return context.tenant.tenantId === tenantId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { AppError } from '../lib/errors.js';
|
import { AppError } from '../lib/errors.js';
|
||||||
import { isDatabaseConfigured, query, withTransaction } from './db.js';
|
import { isDatabaseConfigured, query, withTransaction } from './db.js';
|
||||||
import { requireTenantContext } from './actor-context.js';
|
import { loadActorContext, requireTenantContext } from './actor-context.js';
|
||||||
import { invokeVertexMultimodalModel } from './llm.js';
|
import { invokeVertexMultimodalModel } from './llm.js';
|
||||||
|
|
||||||
export const VerificationStatus = Object.freeze({
|
export const VerificationStatus = Object.freeze({
|
||||||
@@ -95,7 +95,11 @@ async function processVerificationJobInMemory(verificationId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function accessMode() {
|
function accessMode() {
|
||||||
return process.env.VERIFICATION_ACCESS_MODE || 'authenticated';
|
const mode = `${process.env.VERIFICATION_ACCESS_MODE || 'tenant'}`.trim().toLowerCase();
|
||||||
|
if (mode === 'owner' || mode === 'tenant' || mode === 'authenticated') {
|
||||||
|
return mode;
|
||||||
|
}
|
||||||
|
return 'tenant';
|
||||||
}
|
}
|
||||||
|
|
||||||
function providerTimeoutMs() {
|
function providerTimeoutMs() {
|
||||||
@@ -156,12 +160,27 @@ function toPublicJob(row) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertAccess(row, actorUid) {
|
async function assertAccess(row, actorUid) {
|
||||||
if (accessMode() === 'authenticated') {
|
if (row.owner_user_id === actorUid) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (row.owner_user_id !== actorUid) {
|
|
||||||
throw new AppError('FORBIDDEN', 'Not allowed to access this verification', 403);
|
const mode = accessMode();
|
||||||
|
if (mode === 'authenticated') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'owner' || !row.tenant_id) {
|
||||||
|
throw new AppError('FORBIDDEN', 'Not allowed to access this verification', 403, {
|
||||||
|
verificationId: row.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const actorContext = await loadActorContext(actorUid);
|
||||||
|
if (actorContext.tenant?.tenantId !== row.tenant_id) {
|
||||||
|
throw new AppError('FORBIDDEN', 'Not allowed to access this verification', 403, {
|
||||||
|
verificationId: row.id,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -614,19 +633,19 @@ export async function createVerificationJob({ actorUid, payload }) {
|
|||||||
export async function getVerificationJob(verificationId, actorUid) {
|
export async function getVerificationJob(verificationId, actorUid) {
|
||||||
if (useMemoryStore()) {
|
if (useMemoryStore()) {
|
||||||
const job = loadMemoryJob(verificationId);
|
const job = loadMemoryJob(verificationId);
|
||||||
assertAccess(job, actorUid);
|
await assertAccess(job, actorUid);
|
||||||
return toPublicJob(job);
|
return toPublicJob(job);
|
||||||
}
|
}
|
||||||
|
|
||||||
const job = await loadJob(verificationId);
|
const job = await loadJob(verificationId);
|
||||||
assertAccess(job, actorUid);
|
await assertAccess(job, actorUid);
|
||||||
return toPublicJob(job);
|
return toPublicJob(job);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function reviewVerificationJob(verificationId, actorUid, review) {
|
export async function reviewVerificationJob(verificationId, actorUid, review) {
|
||||||
if (useMemoryStore()) {
|
if (useMemoryStore()) {
|
||||||
const job = loadMemoryJob(verificationId);
|
const job = loadMemoryJob(verificationId);
|
||||||
assertAccess(job, actorUid);
|
await assertAccess(job, actorUid);
|
||||||
if (HUMAN_TERMINAL_STATUSES.has(job.status)) {
|
if (HUMAN_TERMINAL_STATUSES.has(job.status)) {
|
||||||
throw new AppError('CONFLICT', 'Verification already finalized', 409, {
|
throw new AppError('CONFLICT', 'Verification already finalized', 409, {
|
||||||
verificationId,
|
verificationId,
|
||||||
@@ -668,7 +687,7 @@ export async function reviewVerificationJob(verificationId, actorUid, review) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const job = result.rows[0];
|
const job = result.rows[0];
|
||||||
assertAccess(job, actorUid);
|
await assertAccess(job, actorUid);
|
||||||
if (HUMAN_TERMINAL_STATUSES.has(job.status)) {
|
if (HUMAN_TERMINAL_STATUSES.has(job.status)) {
|
||||||
throw new AppError('CONFLICT', 'Verification already finalized', 409, {
|
throw new AppError('CONFLICT', 'Verification already finalized', 409, {
|
||||||
verificationId,
|
verificationId,
|
||||||
@@ -735,7 +754,7 @@ export async function reviewVerificationJob(verificationId, actorUid, review) {
|
|||||||
export async function retryVerificationJob(verificationId, actorUid) {
|
export async function retryVerificationJob(verificationId, actorUid) {
|
||||||
if (useMemoryStore()) {
|
if (useMemoryStore()) {
|
||||||
const job = loadMemoryJob(verificationId);
|
const job = loadMemoryJob(verificationId);
|
||||||
assertAccess(job, actorUid);
|
await assertAccess(job, actorUid);
|
||||||
if (job.status === VerificationStatus.PROCESSING) {
|
if (job.status === VerificationStatus.PROCESSING) {
|
||||||
throw new AppError('CONFLICT', 'Cannot retry while verification is processing', 409, {
|
throw new AppError('CONFLICT', 'Cannot retry while verification is processing', 409, {
|
||||||
verificationId,
|
verificationId,
|
||||||
@@ -774,7 +793,7 @@ export async function retryVerificationJob(verificationId, actorUid) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const job = result.rows[0];
|
const job = result.rows[0];
|
||||||
assertAccess(job, actorUid);
|
await assertAccess(job, actorUid);
|
||||||
if (job.status === VerificationStatus.PROCESSING) {
|
if (job.status === VerificationStatus.PROCESSING) {
|
||||||
throw new AppError('CONFLICT', 'Cannot retry while verification is processing', 409, {
|
throw new AppError('CONFLICT', 'Cannot retry while verification is processing', 409, {
|
||||||
verificationId,
|
verificationId,
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import assert from 'node:assert/strict';
|
|||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import { createApp } from '../src/app.js';
|
import { createApp } from '../src/app.js';
|
||||||
import { __resetLlmRateLimitForTests } from '../src/services/llm-rate-limit.js';
|
import { __resetLlmRateLimitForTests } from '../src/services/llm-rate-limit.js';
|
||||||
import { __resetVerificationJobsForTests } from '../src/services/verification-jobs.js';
|
import {
|
||||||
|
__resetVerificationJobsForTests,
|
||||||
|
createVerificationJob,
|
||||||
|
getVerificationJob,
|
||||||
|
} from '../src/services/verification-jobs.js';
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
process.env.AUTH_BYPASS = 'true';
|
process.env.AUTH_BYPASS = 'true';
|
||||||
@@ -13,7 +17,7 @@ beforeEach(async () => {
|
|||||||
process.env.MAX_SIGNED_URL_SECONDS = '900';
|
process.env.MAX_SIGNED_URL_SECONDS = '900';
|
||||||
process.env.LLM_RATE_LIMIT_PER_MINUTE = '20';
|
process.env.LLM_RATE_LIMIT_PER_MINUTE = '20';
|
||||||
process.env.VERIFICATION_REQUIRE_FILE_EXISTS = 'false';
|
process.env.VERIFICATION_REQUIRE_FILE_EXISTS = 'false';
|
||||||
process.env.VERIFICATION_ACCESS_MODE = 'authenticated';
|
process.env.VERIFICATION_ACCESS_MODE = 'tenant';
|
||||||
process.env.VERIFICATION_ATTIRE_PROVIDER = 'mock';
|
process.env.VERIFICATION_ATTIRE_PROVIDER = 'mock';
|
||||||
process.env.VERIFICATION_STORE = 'memory';
|
process.env.VERIFICATION_STORE = 'memory';
|
||||||
__resetLlmRateLimitForTests();
|
__resetLlmRateLimitForTests();
|
||||||
@@ -66,6 +70,16 @@ test('GET /readyz reports database not configured when env is absent', async ()
|
|||||||
assert.equal(res.body.status, 'DATABASE_NOT_CONFIGURED');
|
assert.equal(res.body.status, 'DATABASE_NOT_CONFIGURED');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('createApp fails fast in protected env when unsafe core flags are enabled', async () => {
|
||||||
|
process.env.APP_ENV = 'staging';
|
||||||
|
process.env.AUTH_BYPASS = 'true';
|
||||||
|
|
||||||
|
assert.throws(() => createApp(), /AUTH_BYPASS must be disabled/);
|
||||||
|
|
||||||
|
delete process.env.APP_ENV;
|
||||||
|
process.env.AUTH_BYPASS = 'true';
|
||||||
|
});
|
||||||
|
|
||||||
test('POST /core/create-signed-url requires auth', async () => {
|
test('POST /core/create-signed-url requires auth', async () => {
|
||||||
process.env.AUTH_BYPASS = 'false';
|
process.env.AUTH_BYPASS = 'false';
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
@@ -404,3 +418,24 @@ test('POST /core/verifications/:id/retry requeues verification', async () => {
|
|||||||
assert.equal(retried.status, 202);
|
assert.equal(retried.status, 202);
|
||||||
assert.equal(retried.body.status, 'PENDING');
|
assert.equal(retried.body.status, 'PENDING');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('verification access is denied to a different actor by default', async () => {
|
||||||
|
const created = await createVerificationJob({
|
||||||
|
actorUid: 'owner-user',
|
||||||
|
payload: {
|
||||||
|
type: 'attire',
|
||||||
|
subjectType: 'staff',
|
||||||
|
subjectId: 'staff_1',
|
||||||
|
fileUri: 'gs://krow-workforce-dev-private/uploads/owner-user/attire.jpg',
|
||||||
|
rules: { attireType: 'shoes' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => getVerificationJob(created.verificationId, 'foreign-user'),
|
||||||
|
(error) => {
|
||||||
|
assert.equal(error.code, 'FORBIDDEN');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
33
backend/core-api/test/policy.test.js
Normal file
33
backend/core-api/test/policy.test.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { can } from '../src/services/policy.js';
|
||||||
|
|
||||||
|
test('core actions require tenant scope', async () => {
|
||||||
|
const allowed = await can(
|
||||||
|
'core.verification.read',
|
||||||
|
'verification',
|
||||||
|
{
|
||||||
|
uid: 'user-1',
|
||||||
|
policyContext: {
|
||||||
|
user: { userId: 'user-1' },
|
||||||
|
tenant: { tenantId: 'tenant-1' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
const denied = await can(
|
||||||
|
'core.verification.read',
|
||||||
|
'verification',
|
||||||
|
{
|
||||||
|
uid: 'user-1',
|
||||||
|
policyContext: {
|
||||||
|
user: { userId: 'user-1' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(allowed, true);
|
||||||
|
assert.equal(denied, false);
|
||||||
|
});
|
||||||
@@ -6,10 +6,12 @@ import { errorHandler, notFoundHandler } from './middleware/error-handler.js';
|
|||||||
import { healthRouter } from './routes/health.js';
|
import { healthRouter } from './routes/health.js';
|
||||||
import { createQueryRouter } from './routes/query.js';
|
import { createQueryRouter } from './routes/query.js';
|
||||||
import { createMobileQueryRouter } from './routes/mobile.js';
|
import { createMobileQueryRouter } from './routes/mobile.js';
|
||||||
|
import { assertSafeRuntimeConfig } from './lib/runtime-safety.js';
|
||||||
|
|
||||||
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
|
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
|
||||||
|
|
||||||
export function createApp(options = {}) {
|
export function createApp(options = {}) {
|
||||||
|
assertSafeRuntimeConfig();
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
app.use(requestContext);
|
app.use(requestContext);
|
||||||
|
|||||||
17
backend/query-api/src/lib/runtime-safety.js
Normal file
17
backend/query-api/src/lib/runtime-safety.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
function runtimeEnvName() {
|
||||||
|
return `${process.env.APP_ENV || process.env.NODE_ENV || ''}`.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProtectedEnv() {
|
||||||
|
return ['staging', 'prod', 'production'].includes(runtimeEnvName());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertSafeRuntimeConfig() {
|
||||||
|
if (!isProtectedEnv()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.AUTH_BYPASS === 'true') {
|
||||||
|
throw new Error(`Unsafe query-api runtime config for ${runtimeEnvName()}: AUTH_BYPASS must be disabled`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,30 @@ function getBearerToken(header) {
|
|||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildBypassActor() {
|
||||||
|
let policyContext = {
|
||||||
|
user: { userId: 'test-user' },
|
||||||
|
tenant: { tenantId: '*' },
|
||||||
|
business: { businessId: '*' },
|
||||||
|
staff: { staffId: '*', workforceId: '*' },
|
||||||
|
};
|
||||||
|
|
||||||
|
if (process.env.AUTH_BYPASS_CONTEXT) {
|
||||||
|
try {
|
||||||
|
policyContext = JSON.parse(process.env.AUTH_BYPASS_CONTEXT);
|
||||||
|
} catch (_error) {
|
||||||
|
policyContext = {
|
||||||
|
user: { userId: 'test-user' },
|
||||||
|
tenant: { tenantId: '*' },
|
||||||
|
business: { businessId: '*' },
|
||||||
|
staff: { staffId: '*', workforceId: '*' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { uid: 'test-user', email: 'test@krow.local', role: 'TEST', policyContext };
|
||||||
|
}
|
||||||
|
|
||||||
export async function requireAuth(req, _res, next) {
|
export async function requireAuth(req, _res, next) {
|
||||||
try {
|
try {
|
||||||
const token = getBearerToken(req.get('Authorization'));
|
const token = getBearerToken(req.get('Authorization'));
|
||||||
@@ -17,7 +41,7 @@ export async function requireAuth(req, _res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (process.env.AUTH_BYPASS === 'true') {
|
if (process.env.AUTH_BYPASS === 'true') {
|
||||||
req.actor = { uid: 'test-user', email: 'test@krow.local', role: 'TEST' };
|
req.actor = buildBypassActor();
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,10 +60,14 @@ export async function requireAuth(req, _res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function requirePolicy(action, resource) {
|
export function requirePolicy(action, resource) {
|
||||||
return (req, _res, next) => {
|
return async (req, _res, next) => {
|
||||||
if (!can(action, resource, req.actor)) {
|
try {
|
||||||
|
if (!(await can(action, resource, req.actor, req))) {
|
||||||
return next(new AppError('FORBIDDEN', 'Not allowed to perform this action', 403));
|
return next(new AppError('FORBIDDEN', 'Not allowed to perform this action', 403));
|
||||||
}
|
}
|
||||||
return next();
|
return next();
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import {
|
|||||||
listHubs,
|
listHubs,
|
||||||
listIndustries,
|
listIndustries,
|
||||||
listInvoiceHistory,
|
listInvoiceHistory,
|
||||||
|
listAvailableOrders,
|
||||||
listOpenShifts,
|
listOpenShifts,
|
||||||
listTaxForms,
|
listTaxForms,
|
||||||
listTimeCardEntries,
|
listTimeCardEntries,
|
||||||
@@ -113,6 +114,7 @@ const defaultQueryService = {
|
|||||||
listHubs,
|
listHubs,
|
||||||
listIndustries,
|
listIndustries,
|
||||||
listInvoiceHistory,
|
listInvoiceHistory,
|
||||||
|
listAvailableOrders,
|
||||||
listOpenShifts,
|
listOpenShifts,
|
||||||
listTaxForms,
|
listTaxForms,
|
||||||
listTimeCardEntries,
|
listTimeCardEntries,
|
||||||
@@ -355,9 +357,20 @@ export function createMobileQueryRouter(queryService = defaultQueryService) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/client/shifts/scheduled', requireAuth, requirePolicy('orders.read', 'order'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const items = await queryService.listOrderItemsByDateRange(req.actor.uid, req.query);
|
||||||
|
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) => {
|
router.get('/client/orders/view', requireAuth, requirePolicy('orders.read', 'order'), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const items = await queryService.listOrderItemsByDateRange(req.actor.uid, req.query);
|
const items = await queryService.listOrderItemsByDateRange(req.actor.uid, req.query);
|
||||||
|
res.set('Deprecation', 'true');
|
||||||
|
res.set('Link', '</client/shifts/scheduled>; rel="successor-version"');
|
||||||
return res.status(200).json({ items, requestId: req.requestId });
|
return res.status(200).json({ items, requestId: req.requestId });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return next(error);
|
return next(error);
|
||||||
@@ -544,6 +557,15 @@ export function createMobileQueryRouter(queryService = defaultQueryService) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/staff/orders/available', requireAuth, requirePolicy('shifts.read', 'shift'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const items = await queryService.listAvailableOrders(req.actor.uid, req.query);
|
||||||
|
return res.status(200).json({ items, requestId: req.requestId });
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/staff/shifts/pending', requireAuth, requirePolicy('shifts.read', 'shift'), async (req, res, next) => {
|
router.get('/staff/shifts/pending', requireAuth, requirePolicy('shifts.read', 'shift'), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const items = await queryService.listPendingAssignments(req.actor.uid);
|
const items = await queryService.listPendingAssignments(req.actor.uid);
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ function requireUuid(value, field) {
|
|||||||
export function createQueryRouter(queryService = defaultQueryService) {
|
export function createQueryRouter(queryService = defaultQueryService) {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
function actorBusinessId(actor) {
|
||||||
|
const businessId = actor?.policyContext?.business?.businessId;
|
||||||
|
return businessId && businessId !== '*' ? businessId : null;
|
||||||
|
}
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/tenants/:tenantId/orders',
|
'/tenants/:tenantId/orders',
|
||||||
requireAuth,
|
requireAuth,
|
||||||
@@ -34,9 +39,10 @@ export function createQueryRouter(queryService = defaultQueryService) {
|
|||||||
async (req, res, next) => {
|
async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const tenantId = requireUuid(req.params.tenantId, 'tenantId');
|
const tenantId = requireUuid(req.params.tenantId, 'tenantId');
|
||||||
|
const scopedBusinessId = actorBusinessId(req.actor);
|
||||||
const orders = await queryService.listOrders({
|
const orders = await queryService.listOrders({
|
||||||
tenantId,
|
tenantId,
|
||||||
businessId: req.query.businessId,
|
businessId: scopedBusinessId || req.query.businessId,
|
||||||
status: req.query.status,
|
status: req.query.status,
|
||||||
limit: req.query.limit,
|
limit: req.query.limit,
|
||||||
offset: req.query.offset,
|
offset: req.query.offset,
|
||||||
@@ -57,10 +63,16 @@ export function createQueryRouter(queryService = defaultQueryService) {
|
|||||||
requirePolicy('orders.read', 'order'),
|
requirePolicy('orders.read', 'order'),
|
||||||
async (req, res, next) => {
|
async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
const scopedBusinessId = actorBusinessId(req.actor);
|
||||||
const order = await queryService.getOrderDetail({
|
const order = await queryService.getOrderDetail({
|
||||||
tenantId: requireUuid(req.params.tenantId, 'tenantId'),
|
tenantId: requireUuid(req.params.tenantId, 'tenantId'),
|
||||||
orderId: requireUuid(req.params.orderId, 'orderId'),
|
orderId: requireUuid(req.params.orderId, 'orderId'),
|
||||||
});
|
});
|
||||||
|
if (scopedBusinessId && order.businessId !== scopedBusinessId) {
|
||||||
|
throw new AppError('FORBIDDEN', 'Order is outside actor business scope', 403, {
|
||||||
|
orderId: req.params.orderId,
|
||||||
|
});
|
||||||
|
}
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
...order,
|
...order,
|
||||||
requestId: req.requestId,
|
requestId: req.requestId,
|
||||||
@@ -77,9 +89,10 @@ export function createQueryRouter(queryService = defaultQueryService) {
|
|||||||
requirePolicy('business.favorite-staff.read', 'staff'),
|
requirePolicy('business.favorite-staff.read', 'staff'),
|
||||||
async (req, res, next) => {
|
async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
const scopedBusinessId = actorBusinessId(req.actor);
|
||||||
const items = await queryService.listFavoriteStaff({
|
const items = await queryService.listFavoriteStaff({
|
||||||
tenantId: requireUuid(req.params.tenantId, 'tenantId'),
|
tenantId: requireUuid(req.params.tenantId, 'tenantId'),
|
||||||
businessId: requireUuid(req.params.businessId, 'businessId'),
|
businessId: requireUuid(scopedBusinessId || req.params.businessId, 'businessId'),
|
||||||
limit: req.query.limit,
|
limit: req.query.limit,
|
||||||
offset: req.query.offset,
|
offset: req.query.offset,
|
||||||
});
|
});
|
||||||
@@ -120,12 +133,19 @@ export function createQueryRouter(queryService = defaultQueryService) {
|
|||||||
requirePolicy('attendance.read', 'attendance'),
|
requirePolicy('attendance.read', 'attendance'),
|
||||||
async (req, res, next) => {
|
async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
const scopedBusinessId = actorBusinessId(req.actor);
|
||||||
const attendance = await queryService.getAssignmentAttendance({
|
const attendance = await queryService.getAssignmentAttendance({
|
||||||
tenantId: requireUuid(req.params.tenantId, 'tenantId'),
|
tenantId: requireUuid(req.params.tenantId, 'tenantId'),
|
||||||
assignmentId: requireUuid(req.params.assignmentId, 'assignmentId'),
|
assignmentId: requireUuid(req.params.assignmentId, 'assignmentId'),
|
||||||
});
|
});
|
||||||
|
if (scopedBusinessId && attendance.businessId !== scopedBusinessId) {
|
||||||
|
throw new AppError('FORBIDDEN', 'Assignment attendance is outside actor business scope', 403, {
|
||||||
|
assignmentId: req.params.assignmentId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const { businessId: _businessId, ...publicAttendance } = attendance;
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
...attendance,
|
...publicAttendance,
|
||||||
requestId: req.requestId,
|
requestId: req.requestId,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -60,6 +60,44 @@ function clamp(value, min, max) {
|
|||||||
return Math.min(Math.max(value, min), max);
|
return Math.min(Math.max(value, min), max);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveTimeZone(value) {
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat('en-US', { timeZone: value || 'UTC' }).resolvedOptions().timeZone;
|
||||||
|
} catch {
|
||||||
|
return 'UTC';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateInTimeZone(value, timeZone = 'UTC') {
|
||||||
|
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone: resolveTimeZone(timeZone),
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
}).formatToParts(new Date(value));
|
||||||
|
const map = Object.fromEntries(parts.map((part) => [part.type, part.value]));
|
||||||
|
return `${map.year}-${map.month}-${map.day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimeInTimeZone(value, timeZone = 'UTC') {
|
||||||
|
const parts = new Intl.DateTimeFormat('en-GB', {
|
||||||
|
timeZone: resolveTimeZone(timeZone),
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
}).formatToParts(new Date(value));
|
||||||
|
const map = Object.fromEntries(parts.map((part) => [part.type, part.value]));
|
||||||
|
return `${map.hour}:${map.minute}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function weekdayCodeInTimeZone(value, timeZone = 'UTC') {
|
||||||
|
const label = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: resolveTimeZone(timeZone),
|
||||||
|
weekday: 'short',
|
||||||
|
}).format(new Date(value));
|
||||||
|
return label.slice(0, 3).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
function computeReliabilityScore({
|
function computeReliabilityScore({
|
||||||
totalShifts,
|
totalShifts,
|
||||||
noShowCount,
|
noShowCount,
|
||||||
@@ -1011,6 +1049,189 @@ export async function listAssignedShifts(actorUid, { startDate, endDate }) {
|
|||||||
return result.rows;
|
return result.rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listAvailableOrders(actorUid, { limit, search } = {}) {
|
||||||
|
const context = await requireStaffContext(actorUid);
|
||||||
|
const result = await query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
o.id AS "orderId",
|
||||||
|
COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType",
|
||||||
|
COALESCE(sr.role_id, rc.id) AS "roleId",
|
||||||
|
COALESCE(sr.role_code, rc.code) AS "roleCode",
|
||||||
|
COALESCE(sr.role_name, rc.name) AS "roleName",
|
||||||
|
b.business_name AS "clientName",
|
||||||
|
COALESCE(cp.label, s.location_name) AS location,
|
||||||
|
COALESCE(s.location_address, cp.address) AS "locationAddress",
|
||||||
|
COALESCE(s.timezone, 'UTC') AS timezone,
|
||||||
|
s.id AS "shiftId",
|
||||||
|
s.status AS "shiftStatus",
|
||||||
|
s.starts_at AS "startsAt",
|
||||||
|
s.ends_at AS "endsAt",
|
||||||
|
sr.workers_needed AS "requiredWorkerCount",
|
||||||
|
sr.assigned_count AS "filledCount",
|
||||||
|
COALESCE(sr.pay_rate_cents, 0)::INTEGER AS "hourlyRateCents",
|
||||||
|
COALESCE((sr.metadata->>'instantBook')::boolean, FALSE) AS "instantBook",
|
||||||
|
COALESCE(dispatch.team_type, 'MARKETPLACE') AS "dispatchTeam",
|
||||||
|
COALESCE(dispatch.priority, 3) AS "dispatchPriority"
|
||||||
|
FROM orders o
|
||||||
|
JOIN shifts s ON s.order_id = o.id
|
||||||
|
JOIN shift_roles sr ON sr.shift_id = s.id
|
||||||
|
LEFT JOIN roles_catalog rc
|
||||||
|
ON rc.tenant_id = o.tenant_id
|
||||||
|
AND (rc.id = sr.role_id OR (sr.role_id IS NULL AND rc.code = sr.role_code))
|
||||||
|
JOIN businesses b ON b.id = o.business_id
|
||||||
|
LEFT JOIN clock_points cp ON cp.id = s.clock_point_id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT
|
||||||
|
dtm.team_type,
|
||||||
|
CASE dtm.team_type
|
||||||
|
WHEN 'CORE' THEN 1
|
||||||
|
WHEN 'CERTIFIED_LOCATION' THEN 2
|
||||||
|
ELSE 3
|
||||||
|
END AS priority
|
||||||
|
FROM dispatch_team_memberships dtm
|
||||||
|
WHERE dtm.tenant_id = $1
|
||||||
|
AND dtm.business_id = s.business_id
|
||||||
|
AND dtm.staff_id = $3
|
||||||
|
AND dtm.status = 'ACTIVE'
|
||||||
|
AND dtm.effective_at <= NOW()
|
||||||
|
AND (dtm.expires_at IS NULL OR dtm.expires_at > NOW())
|
||||||
|
AND (dtm.hub_id IS NULL OR dtm.hub_id = s.clock_point_id)
|
||||||
|
ORDER BY
|
||||||
|
CASE dtm.team_type
|
||||||
|
WHEN 'CORE' THEN 1
|
||||||
|
WHEN 'CERTIFIED_LOCATION' THEN 2
|
||||||
|
ELSE 3
|
||||||
|
END ASC,
|
||||||
|
CASE WHEN dtm.hub_id = s.clock_point_id THEN 0 ELSE 1 END ASC,
|
||||||
|
dtm.created_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
) dispatch ON TRUE
|
||||||
|
WHERE o.tenant_id = $1
|
||||||
|
AND s.starts_at > NOW()
|
||||||
|
AND COALESCE(sr.role_code, rc.code) = $4
|
||||||
|
AND ($2::text IS NULL OR COALESCE(sr.role_name, rc.name) ILIKE '%' || $2 || '%' OR COALESCE(cp.label, s.location_name) ILIKE '%' || $2 || '%' OR b.business_name ILIKE '%' || $2 || '%')
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM staff_blocks sb
|
||||||
|
WHERE sb.tenant_id = o.tenant_id
|
||||||
|
AND sb.business_id = o.business_id
|
||||||
|
AND sb.staff_id = $3
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM applications a
|
||||||
|
JOIN shifts sx ON sx.id = a.shift_id
|
||||||
|
WHERE sx.order_id = o.id
|
||||||
|
AND a.staff_id = $3
|
||||||
|
AND a.status IN ('PENDING', 'CONFIRMED', 'CHECKED_IN', 'COMPLETED')
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM assignments a
|
||||||
|
JOIN shifts sx ON sx.id = a.shift_id
|
||||||
|
WHERE sx.order_id = o.id
|
||||||
|
AND a.staff_id = $3
|
||||||
|
AND a.status IN ('ASSIGNED', 'ACCEPTED', 'SWAP_REQUESTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED')
|
||||||
|
)
|
||||||
|
ORDER BY COALESCE(dispatch.priority, 3) ASC, s.starts_at ASC
|
||||||
|
LIMIT $5
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
context.tenant.tenantId,
|
||||||
|
search || null,
|
||||||
|
context.staff.staffId,
|
||||||
|
context.staff.primaryRole || 'BARISTA',
|
||||||
|
parseLimit(limit, 50, 250),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const grouped = new Map();
|
||||||
|
for (const row of result.rows) {
|
||||||
|
const key = `${row.orderId}:${row.roleId}`;
|
||||||
|
const existing = grouped.get(key) || {
|
||||||
|
orderId: row.orderId,
|
||||||
|
orderType: row.orderType,
|
||||||
|
roleId: row.roleId,
|
||||||
|
roleCode: row.roleCode,
|
||||||
|
roleName: row.roleName,
|
||||||
|
clientName: row.clientName,
|
||||||
|
location: row.location,
|
||||||
|
locationAddress: row.locationAddress,
|
||||||
|
hourlyRateCents: row.hourlyRateCents,
|
||||||
|
hourlyRate: Number((row.hourlyRateCents / 100).toFixed(2)),
|
||||||
|
requiredWorkerCount: row.requiredWorkerCount,
|
||||||
|
filledCount: row.filledCount,
|
||||||
|
instantBook: Boolean(row.instantBook),
|
||||||
|
dispatchTeam: row.dispatchTeam,
|
||||||
|
dispatchPriority: row.dispatchPriority,
|
||||||
|
timezone: resolveTimeZone(row.timezone),
|
||||||
|
shifts: [],
|
||||||
|
};
|
||||||
|
existing.requiredWorkerCount = Math.max(existing.requiredWorkerCount, row.requiredWorkerCount);
|
||||||
|
existing.filledCount = Math.max(existing.filledCount, row.filledCount);
|
||||||
|
existing.instantBook = existing.instantBook && Boolean(row.instantBook);
|
||||||
|
existing.dispatchPriority = Math.min(existing.dispatchPriority, row.dispatchPriority);
|
||||||
|
existing.dispatchTeam = existing.dispatchPriority === 1
|
||||||
|
? 'CORE'
|
||||||
|
: existing.dispatchPriority === 2
|
||||||
|
? 'CERTIFIED_LOCATION'
|
||||||
|
: 'MARKETPLACE';
|
||||||
|
existing.shifts.push({
|
||||||
|
shiftId: row.shiftId,
|
||||||
|
shiftStatus: row.shiftStatus,
|
||||||
|
startsAt: row.startsAt,
|
||||||
|
endsAt: row.endsAt,
|
||||||
|
});
|
||||||
|
grouped.set(key, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(grouped.values())
|
||||||
|
.filter((item) => item.shifts.length > 0)
|
||||||
|
.filter((item) => item.shifts.every((shift) => shift.shiftStatus === 'OPEN'))
|
||||||
|
.filter((item) => item.filledCount < item.requiredWorkerCount)
|
||||||
|
.sort((left, right) => {
|
||||||
|
if (left.dispatchPriority !== right.dispatchPriority) {
|
||||||
|
return left.dispatchPriority - right.dispatchPriority;
|
||||||
|
}
|
||||||
|
return new Date(left.shifts[0].startsAt).getTime() - new Date(right.shifts[0].startsAt).getTime();
|
||||||
|
})
|
||||||
|
.slice(0, parseLimit(limit, 20, 100))
|
||||||
|
.map((item) => {
|
||||||
|
const firstShift = item.shifts[0];
|
||||||
|
const lastShift = item.shifts[item.shifts.length - 1];
|
||||||
|
const daysOfWeek = [...new Set(item.shifts.map((shift) => weekdayCodeInTimeZone(shift.startsAt, item.timezone)))];
|
||||||
|
return {
|
||||||
|
orderId: item.orderId,
|
||||||
|
orderType: item.orderType,
|
||||||
|
roleId: item.roleId,
|
||||||
|
roleCode: item.roleCode,
|
||||||
|
roleName: item.roleName,
|
||||||
|
clientName: item.clientName,
|
||||||
|
location: item.location,
|
||||||
|
locationAddress: item.locationAddress,
|
||||||
|
hourlyRateCents: item.hourlyRateCents,
|
||||||
|
hourlyRate: item.hourlyRate,
|
||||||
|
requiredWorkerCount: item.requiredWorkerCount,
|
||||||
|
filledCount: item.filledCount,
|
||||||
|
instantBook: item.instantBook,
|
||||||
|
dispatchTeam: item.dispatchTeam,
|
||||||
|
dispatchPriority: item.dispatchPriority,
|
||||||
|
schedule: {
|
||||||
|
totalShifts: item.shifts.length,
|
||||||
|
startDate: formatDateInTimeZone(firstShift.startsAt, item.timezone),
|
||||||
|
endDate: formatDateInTimeZone(lastShift.startsAt, item.timezone),
|
||||||
|
daysOfWeek,
|
||||||
|
startTime: formatTimeInTimeZone(firstShift.startsAt, item.timezone),
|
||||||
|
endTime: formatTimeInTimeZone(firstShift.endsAt, item.timezone),
|
||||||
|
timezone: item.timezone,
|
||||||
|
firstShiftStartsAt: firstShift.startsAt,
|
||||||
|
lastShiftEndsAt: lastShift.endsAt,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function listOpenShifts(actorUid, { limit, search } = {}) {
|
export async function listOpenShifts(actorUid, { limit, search } = {}) {
|
||||||
const context = await requireStaffContext(actorUid);
|
const context = await requireStaffContext(actorUid);
|
||||||
const result = await query(
|
const result = await query(
|
||||||
|
|||||||
@@ -1,5 +1,118 @@
|
|||||||
export function can(action, resource, actor) {
|
import { loadActorContext } from './actor-context.js';
|
||||||
void action;
|
|
||||||
void resource;
|
const TENANT_ADMIN_ROLES = new Set(['OWNER', 'ADMIN']);
|
||||||
return Boolean(actor?.uid);
|
|
||||||
|
function normalize(value) {
|
||||||
|
return `${value || ''}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestField(req, field) {
|
||||||
|
return normalize(
|
||||||
|
req?.params?.[field]
|
||||||
|
?? req?.body?.[field]
|
||||||
|
?? req?.query?.[field]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTenantAdmin(context) {
|
||||||
|
return TENANT_ADMIN_ROLES.has(normalize(context?.tenant?.role).toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasTenantScope(context) {
|
||||||
|
return Boolean(context?.user && context?.tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasClientScope(context) {
|
||||||
|
return hasTenantScope(context) && Boolean(context?.business || isTenantAdmin(context));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasStaffScope(context) {
|
||||||
|
return hasTenantScope(context) && Boolean(context?.staff);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredScopeFor(action) {
|
||||||
|
if (action === 'attendance.read') {
|
||||||
|
return 'tenant';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
action === 'orders.read'
|
||||||
|
|| action === 'orders.reorder.read'
|
||||||
|
|| action === 'business.favorite-staff.read'
|
||||||
|
|| action === 'staff.reviews.read'
|
||||||
|
|| action.startsWith('client.')
|
||||||
|
|| action.startsWith('billing.')
|
||||||
|
|| action.startsWith('coverage.')
|
||||||
|
|| action.startsWith('hubs.')
|
||||||
|
|| action.startsWith('vendors.')
|
||||||
|
|| action.startsWith('reports.')
|
||||||
|
) {
|
||||||
|
return 'client';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
action === 'shifts.read'
|
||||||
|
|| action.startsWith('staff.')
|
||||||
|
|| action.startsWith('payments.')
|
||||||
|
) {
|
||||||
|
return 'staff';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'deny';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveActorContext(actor) {
|
||||||
|
if (!actor?.uid) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (actor.policyContext) {
|
||||||
|
return actor.policyContext;
|
||||||
|
}
|
||||||
|
const context = await loadActorContext(actor.uid);
|
||||||
|
actor.policyContext = context;
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestScopeMatches(req, context, requiredScope) {
|
||||||
|
const tenantId = requestField(req, 'tenantId');
|
||||||
|
if (tenantId && context?.tenant?.tenantId !== '*' && context?.tenant?.tenantId !== tenantId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const businessId = requestField(req, 'businessId');
|
||||||
|
if (
|
||||||
|
requiredScope === 'client'
|
||||||
|
&& businessId
|
||||||
|
&& context?.business?.businessId
|
||||||
|
&& context.business.businessId !== '*'
|
||||||
|
&& context.business.businessId !== businessId
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function can(action, resource, actor, req) {
|
||||||
|
void resource;
|
||||||
|
const context = await resolveActorContext(actor);
|
||||||
|
const requiredScope = requiredScopeFor(action);
|
||||||
|
|
||||||
|
if (requiredScope === 'deny' || !context?.user) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requiredScope === 'tenant') {
|
||||||
|
return hasTenantScope(context) && requestScopeMatches(req, context, requiredScope);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requiredScope === 'client') {
|
||||||
|
return hasClientScope(context) && requestScopeMatches(req, context, requiredScope);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requiredScope === 'staff') {
|
||||||
|
return hasStaffScope(context) && requestScopeMatches(req, context, requiredScope);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -233,6 +233,7 @@ export async function getAssignmentAttendance({ tenantId, assignmentId }) {
|
|||||||
SELECT
|
SELECT
|
||||||
a.id AS "assignmentId",
|
a.id AS "assignmentId",
|
||||||
a.status,
|
a.status,
|
||||||
|
a.business_id AS "businessId",
|
||||||
a.shift_id AS "shiftId",
|
a.shift_id AS "shiftId",
|
||||||
a.staff_id AS "staffId",
|
a.staff_id AS "staffId",
|
||||||
s.title AS "shiftTitle",
|
s.title AS "shiftTitle",
|
||||||
|
|||||||
@@ -37,6 +37,20 @@ test('GET /readyz reports database not configured when no database env is presen
|
|||||||
assert.equal(res.body.status, 'DATABASE_NOT_CONFIGURED');
|
assert.equal(res.body.status, 'DATABASE_NOT_CONFIGURED');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.afterEach(() => {
|
||||||
|
delete process.env.AUTH_BYPASS_CONTEXT;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('createApp fails fast in protected env when auth bypass is enabled', async () => {
|
||||||
|
process.env.APP_ENV = 'staging';
|
||||||
|
process.env.AUTH_BYPASS = 'true';
|
||||||
|
|
||||||
|
assert.throws(() => createApp(), /AUTH_BYPASS must be disabled/);
|
||||||
|
|
||||||
|
delete process.env.APP_ENV;
|
||||||
|
process.env.AUTH_BYPASS = 'true';
|
||||||
|
});
|
||||||
|
|
||||||
test('GET unknown route returns not found envelope', async () => {
|
test('GET unknown route returns not found envelope', async () => {
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
const res = await request(app).get('/query/unknown');
|
const res = await request(app).get('/query/unknown');
|
||||||
@@ -124,3 +138,28 @@ test('GET /query/tenants/:tenantId/businesses/:businessId/favorite-staff validat
|
|||||||
assert.equal(res.status, 200);
|
assert.equal(res.status, 200);
|
||||||
assert.equal(res.body.items[0].staffId, staffId);
|
assert.equal(res.body.items[0].staffId, staffId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('GET /query/tenants/:tenantId/orders denies mismatched tenant scope before handler execution', async () => {
|
||||||
|
process.env.AUTH_BYPASS_CONTEXT = JSON.stringify({
|
||||||
|
user: { userId: 'test-user' },
|
||||||
|
tenant: { tenantId: '99999999-9999-4999-8999-999999999999', role: 'MANAGER' },
|
||||||
|
business: { businessId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = createApp({
|
||||||
|
queryService: {
|
||||||
|
listOrders: async () => assert.fail('listOrders should not be called'),
|
||||||
|
getOrderDetail: async () => assert.fail('getOrderDetail should not be called'),
|
||||||
|
listFavoriteStaff: async () => assert.fail('listFavoriteStaff should not be called'),
|
||||||
|
getStaffReviewSummary: async () => assert.fail('getStaffReviewSummary should not be called'),
|
||||||
|
getAssignmentAttendance: async () => assert.fail('getAssignmentAttendance should not be called'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/query/tenants/${tenantId}/orders`)
|
||||||
|
.set('Authorization', 'Bearer test-token');
|
||||||
|
|
||||||
|
assert.equal(res.status, 403);
|
||||||
|
assert.equal(res.body.code, 'FORBIDDEN');
|
||||||
|
});
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ function createMobileQueryService() {
|
|||||||
listHubs: async () => ([{ hubId: 'hub-1' }]),
|
listHubs: async () => ([{ hubId: 'hub-1' }]),
|
||||||
listIndustries: async () => (['CATERING']),
|
listIndustries: async () => (['CATERING']),
|
||||||
listInvoiceHistory: async () => ([{ invoiceId: 'inv-1' }]),
|
listInvoiceHistory: async () => ([{ invoiceId: 'inv-1' }]),
|
||||||
|
listAvailableOrders: async () => ([{ orderId: 'order-available-1', roleId: 'role-catalog-1' }]),
|
||||||
listOpenShifts: async () => ([{ shiftId: 'open-1' }]),
|
listOpenShifts: async () => ([{ shiftId: 'open-1' }]),
|
||||||
getOrderReorderPreview: async () => ({ orderId: 'order-1', lines: 2 }),
|
getOrderReorderPreview: async () => ({ orderId: 'order-1', lines: 2 }),
|
||||||
listOrderItemsByDateRange: async () => ([{ itemId: 'item-1' }]),
|
listOrderItemsByDateRange: async () => ([{ itemId: 'item-1' }]),
|
||||||
@@ -123,6 +124,39 @@ test('GET /query/staff/shifts/:shiftId returns injected shift detail', async ()
|
|||||||
assert.equal(res.body.shiftId, 'shift-1');
|
assert.equal(res.body.shiftId, 'shift-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('GET /query/staff/orders/available returns injected order-level opportunities', async () => {
|
||||||
|
const app = createApp({ mobileQueryService: createMobileQueryService() });
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/query/staff/orders/available')
|
||||||
|
.set('Authorization', 'Bearer test-token');
|
||||||
|
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.body.items[0].orderId, 'order-available-1');
|
||||||
|
assert.equal(res.body.items[0].roleId, 'role-catalog-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /query/client/shifts/scheduled returns injected shift timeline items', async () => {
|
||||||
|
const app = createApp({ mobileQueryService: createMobileQueryService() });
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/query/client/shifts/scheduled?startDate=2026-03-13T00:00:00.000Z&endDate=2026-03-20T00:00:00.000Z')
|
||||||
|
.set('Authorization', 'Bearer test-token');
|
||||||
|
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.body.items[0].itemId, 'item-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /query/client/orders/view remains as deprecated compatibility alias', async () => {
|
||||||
|
const app = createApp({ mobileQueryService: createMobileQueryService() });
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/query/client/orders/view?startDate=2026-03-13T00:00:00.000Z&endDate=2026-03-20T00:00:00.000Z')
|
||||||
|
.set('Authorization', 'Bearer test-token');
|
||||||
|
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.headers.deprecation, 'true');
|
||||||
|
assert.equal(res.headers.link, '</client/shifts/scheduled>; rel="successor-version"');
|
||||||
|
assert.equal(res.body.items[0].itemId, 'item-1');
|
||||||
|
});
|
||||||
|
|
||||||
test('GET /query/client/reports/summary returns injected report summary', async () => {
|
test('GET /query/client/reports/summary returns injected report summary', async () => {
|
||||||
const app = createApp({ mobileQueryService: createMobileQueryService() });
|
const app = createApp({ mobileQueryService: createMobileQueryService() });
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
|
|||||||
86
backend/query-api/test/policy.test.js
Normal file
86
backend/query-api/test/policy.test.js
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { can } from '../src/services/policy.js';
|
||||||
|
|
||||||
|
test('orders.read requires client scope and matching tenant/business scope', async () => {
|
||||||
|
const allowed = await can(
|
||||||
|
'orders.read',
|
||||||
|
'order',
|
||||||
|
{
|
||||||
|
uid: 'user-1',
|
||||||
|
policyContext: {
|
||||||
|
user: { userId: 'user-1' },
|
||||||
|
tenant: { tenantId: 'tenant-1', role: 'MANAGER' },
|
||||||
|
business: { businessId: 'business-1' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ params: { tenantId: 'tenant-1' }, query: { businessId: 'business-1' } }
|
||||||
|
);
|
||||||
|
|
||||||
|
const denied = await can(
|
||||||
|
'orders.read',
|
||||||
|
'order',
|
||||||
|
{
|
||||||
|
uid: 'user-1',
|
||||||
|
policyContext: {
|
||||||
|
user: { userId: 'user-1' },
|
||||||
|
tenant: { tenantId: 'tenant-1', role: 'MANAGER' },
|
||||||
|
business: { businessId: 'business-1' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ params: { tenantId: 'tenant-2' }, query: { businessId: 'business-1' } }
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(allowed, true);
|
||||||
|
assert.equal(denied, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shifts.read requires staff scope', async () => {
|
||||||
|
const allowed = await can(
|
||||||
|
'shifts.read',
|
||||||
|
'shift',
|
||||||
|
{
|
||||||
|
uid: 'user-1',
|
||||||
|
policyContext: {
|
||||||
|
user: { userId: 'user-1' },
|
||||||
|
tenant: { tenantId: 'tenant-1' },
|
||||||
|
staff: { staffId: 'staff-1' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ params: {} }
|
||||||
|
);
|
||||||
|
|
||||||
|
const denied = await can(
|
||||||
|
'shifts.read',
|
||||||
|
'shift',
|
||||||
|
{
|
||||||
|
uid: 'user-1',
|
||||||
|
policyContext: {
|
||||||
|
user: { userId: 'user-1' },
|
||||||
|
tenant: { tenantId: 'tenant-1' },
|
||||||
|
business: { businessId: 'business-1' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ params: {} }
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(allowed, true);
|
||||||
|
assert.equal(denied, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('attendance.read allows tenant-scoped actor', async () => {
|
||||||
|
const allowed = await can(
|
||||||
|
'attendance.read',
|
||||||
|
'attendance',
|
||||||
|
{
|
||||||
|
uid: 'user-1',
|
||||||
|
policyContext: {
|
||||||
|
user: { userId: 'user-1' },
|
||||||
|
tenant: { tenantId: 'tenant-1' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ params: { tenantId: 'tenant-1' } }
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(allowed, true);
|
||||||
|
});
|
||||||
@@ -449,6 +449,16 @@ async function main() {
|
|||||||
}
|
}
|
||||||
logStep('client.orders.view.ok', { count: viewedOrders.items.length });
|
logStep('client.orders.view.ok', { count: viewedOrders.items.length });
|
||||||
|
|
||||||
|
const scheduledShifts = await apiCall(`/client/shifts/scheduled?${reportWindow}`, {
|
||||||
|
token: ownerSession.sessionToken,
|
||||||
|
});
|
||||||
|
assert.ok(Array.isArray(scheduledShifts.items));
|
||||||
|
assert.equal(scheduledShifts.items.length, viewedOrders.items.length);
|
||||||
|
if (viewedOrders.items[0] && scheduledShifts.items[0]) {
|
||||||
|
assert.equal(scheduledShifts.items[0].itemId, viewedOrders.items[0].itemId);
|
||||||
|
}
|
||||||
|
logStep('client.shifts.scheduled.ok', { count: scheduledShifts.items.length });
|
||||||
|
|
||||||
const reorderPreview = await apiCall(`/client/orders/${fixture.orders.completed.id}/reorder-preview`, {
|
const reorderPreview = await apiCall(`/client/orders/${fixture.orders.completed.id}/reorder-preview`, {
|
||||||
token: ownerSession.sessionToken,
|
token: ownerSession.sessionToken,
|
||||||
});
|
});
|
||||||
@@ -814,6 +824,33 @@ async function main() {
|
|||||||
assert.ok(Array.isArray(assignedShifts.items));
|
assert.ok(Array.isArray(assignedShifts.items));
|
||||||
logStep('staff.shifts.assigned.ok', { count: assignedShifts.items.length });
|
logStep('staff.shifts.assigned.ok', { count: assignedShifts.items.length });
|
||||||
|
|
||||||
|
const availableOrders = await apiCall('/staff/orders/available?limit=20', {
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
});
|
||||||
|
const availableOrder = availableOrders.items.find((item) => item.orderId === createdRecurringOrder.orderId)
|
||||||
|
|| availableOrders.items[0];
|
||||||
|
assert.ok(availableOrder);
|
||||||
|
assert.ok(availableOrder.roleId);
|
||||||
|
logStep('staff.orders.available.ok', { count: availableOrders.items.length, orderId: availableOrder.orderId });
|
||||||
|
|
||||||
|
const bookedOrder = await apiCall(`/staff/orders/${availableOrder.orderId}/book`, {
|
||||||
|
method: 'POST',
|
||||||
|
token: staffAuth.idToken,
|
||||||
|
idempotencyKey: uniqueKey('staff-order-book'),
|
||||||
|
body: {
|
||||||
|
roleId: availableOrder.roleId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(bookedOrder.orderId, availableOrder.orderId);
|
||||||
|
assert.ok(bookedOrder.assignedShiftCount >= 1);
|
||||||
|
assert.equal(bookedOrder.status, 'PENDING');
|
||||||
|
assert.ok(Array.isArray(bookedOrder.assignedShifts));
|
||||||
|
logStep('staff.orders.book.ok', {
|
||||||
|
orderId: bookedOrder.orderId,
|
||||||
|
assignedShiftCount: bookedOrder.assignedShiftCount,
|
||||||
|
status: bookedOrder.status,
|
||||||
|
});
|
||||||
|
|
||||||
const openShifts = await apiCall('/staff/shifts/open', {
|
const openShifts = await apiCall('/staff/shifts/open', {
|
||||||
token: staffAuth.idToken,
|
token: staffAuth.idToken,
|
||||||
});
|
});
|
||||||
@@ -827,6 +864,9 @@ async function main() {
|
|||||||
const pendingShifts = await apiCall('/staff/shifts/pending', {
|
const pendingShifts = await apiCall('/staff/shifts/pending', {
|
||||||
token: staffAuth.idToken,
|
token: staffAuth.idToken,
|
||||||
});
|
});
|
||||||
|
assert.ok(
|
||||||
|
bookedOrder.assignedShifts.some((shift) => pendingShifts.items.some((item) => item.shiftId === shift.shiftId))
|
||||||
|
);
|
||||||
const pendingShift = pendingShifts.items.find((item) => item.shiftId === fixture.shifts.available.id)
|
const pendingShift = pendingShifts.items.find((item) => item.shiftId === fixture.shifts.available.id)
|
||||||
|| pendingShifts.items[0];
|
|| pendingShifts.items[0];
|
||||||
assert.ok(pendingShift);
|
assert.ok(pendingShift);
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import { errorHandler, notFoundHandler } from './middleware/error-handler.js';
|
|||||||
import { healthRouter } from './routes/health.js';
|
import { healthRouter } from './routes/health.js';
|
||||||
import { createAuthRouter } from './routes/auth.js';
|
import { createAuthRouter } from './routes/auth.js';
|
||||||
import { createProxyRouter } from './routes/proxy.js';
|
import { createProxyRouter } from './routes/proxy.js';
|
||||||
|
import { assertSafeRuntimeConfig } from './lib/runtime-safety.js';
|
||||||
|
|
||||||
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
|
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
|
||||||
|
|
||||||
export function createApp(options = {}) {
|
export function createApp(options = {}) {
|
||||||
|
assertSafeRuntimeConfig();
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
app.use(requestContext);
|
app.use(requestContext);
|
||||||
|
|||||||
35
backend/unified-api/src/lib/runtime-safety.js
Normal file
35
backend/unified-api/src/lib/runtime-safety.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
function runtimeEnvName() {
|
||||||
|
return `${process.env.APP_ENV || process.env.NODE_ENV || ''}`.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProtectedEnv() {
|
||||||
|
return ['staging', 'prod', 'production'].includes(runtimeEnvName());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertSafeRuntimeConfig() {
|
||||||
|
if (!isProtectedEnv()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
if (process.env.AUTH_BYPASS === 'true') {
|
||||||
|
errors.push('AUTH_BYPASS must be disabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!process.env.CORE_API_BASE_URL) {
|
||||||
|
errors.push('CORE_API_BASE_URL is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!process.env.COMMAND_API_BASE_URL) {
|
||||||
|
errors.push('COMMAND_API_BASE_URL is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!process.env.QUERY_API_BASE_URL) {
|
||||||
|
errors.push('QUERY_API_BASE_URL is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
throw new Error(`Unsafe unified-api runtime config for ${runtimeEnvName()}: ${errors.join('; ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,19 @@ test('GET /readyz reports database not configured when env is absent', async ()
|
|||||||
assert.equal(res.body.status, 'DATABASE_NOT_CONFIGURED');
|
assert.equal(res.body.status, 'DATABASE_NOT_CONFIGURED');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('createApp fails fast in protected env when upstream config is unsafe', async () => {
|
||||||
|
process.env.APP_ENV = 'staging';
|
||||||
|
process.env.AUTH_BYPASS = 'true';
|
||||||
|
delete process.env.CORE_API_BASE_URL;
|
||||||
|
delete process.env.COMMAND_API_BASE_URL;
|
||||||
|
delete process.env.QUERY_API_BASE_URL;
|
||||||
|
|
||||||
|
assert.throws(() => createApp(), /AUTH_BYPASS must be disabled/);
|
||||||
|
|
||||||
|
delete process.env.APP_ENV;
|
||||||
|
process.env.AUTH_BYPASS = 'true';
|
||||||
|
});
|
||||||
|
|
||||||
test('POST /auth/client/sign-in validates payload', async () => {
|
test('POST /auth/client/sign-in validates payload', async () => {
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
const res = await request(app).post('/auth/client/sign-in').send({
|
const res = await request(app).post('/auth/client/sign-in').send({
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ This is the frontend-facing source of truth for the v2 backend.
|
|||||||
Frontend should call one public gateway:
|
Frontend should call one public gateway:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
API_V2_BASE_URL=https://krow-api-v2-933560802882.us-central1.run.app
|
API_V2_BASE_URL=https://krow-api-v2-e3g6witsvq-uc.a.run.app
|
||||||
```
|
```
|
||||||
|
|
||||||
Frontend should not call the internal `core`, `command`, or `query` Cloud Run services directly.
|
Frontend should not call the internal `core`, `command`, or `query` Cloud Run services directly.
|
||||||
@@ -95,14 +95,12 @@ Source-of-truth timestamp fields include:
|
|||||||
|
|
||||||
- `startsAt`
|
- `startsAt`
|
||||||
- `endsAt`
|
- `endsAt`
|
||||||
- `startTime`
|
|
||||||
- `endTime`
|
|
||||||
- `clockInAt`
|
- `clockInAt`
|
||||||
- `clockOutAt`
|
- `clockOutAt`
|
||||||
- `createdAt`
|
- `createdAt`
|
||||||
- `updatedAt`
|
- `updatedAt`
|
||||||
|
|
||||||
Helper fields like `date` are UTC-derived helpers and should not replace the raw timestamp fields.
|
Helper fields like `date`, `startTime`, and `endTime` are display helpers and should not replace the raw timestamp fields.
|
||||||
|
|
||||||
## 4) Attendance policy and monitoring
|
## 4) Attendance policy and monitoring
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ This document is the source of truth for V2 authentication.
|
|||||||
Base URL:
|
Base URL:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
API_V2_BASE_URL=https://krow-api-v2-933560802882.us-central1.run.app
|
API_V2_BASE_URL=https://krow-api-v2-e3g6witsvq-uc.a.run.app
|
||||||
```
|
```
|
||||||
|
|
||||||
## 1) What is implemented
|
## 1) What is implemented
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ That includes:
|
|||||||
|
|
||||||
The live smoke executed successfully against:
|
The live smoke executed successfully against:
|
||||||
|
|
||||||
- `https://krow-api-v2-933560802882.us-central1.run.app`
|
- `https://krow-api-v2-e3g6witsvq-uc.a.run.app`
|
||||||
- Firebase demo users
|
- Firebase demo users
|
||||||
- `krow-sql-v2`
|
- `krow-sql-v2`
|
||||||
- `krow-core-api-v2`
|
- `krow-core-api-v2`
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Use this as the primary implementation brief.
|
|||||||
|
|
||||||
Base URL:
|
Base URL:
|
||||||
|
|
||||||
- `https://krow-api-v2-933560802882.us-central1.run.app`
|
- `https://krow-api-v2-e3g6witsvq-uc.a.run.app`
|
||||||
|
|
||||||
Supporting docs:
|
Supporting docs:
|
||||||
|
|
||||||
@@ -23,6 +23,7 @@ Supporting docs:
|
|||||||
- Send `Idempotency-Key` on every write route.
|
- Send `Idempotency-Key` on every write route.
|
||||||
- Treat `order`, `shift`, `shiftRole`, and `assignment` as different objects.
|
- Treat `order`, `shift`, `shiftRole`, and `assignment` as different objects.
|
||||||
- For staff shift applications, `roleId` must come from the response of `GET /staff/shifts/open`.
|
- For staff shift applications, `roleId` must come from the response of `GET /staff/shifts/open`.
|
||||||
|
- For staff order booking, `roleId` must come from the response of `GET /staff/orders/available`.
|
||||||
- Treat API timestamp fields as UTC and convert them to local time in the app.
|
- Treat API timestamp fields as UTC and convert them to local time in the app.
|
||||||
|
|
||||||
## 2) What is implemented now
|
## 2) What is implemented now
|
||||||
@@ -90,8 +91,10 @@ Do not assume staff auth is a fully backend-managed OTP flow.
|
|||||||
Rules:
|
Rules:
|
||||||
|
|
||||||
- `GET /staff/shifts/open` returns opportunities, not assignments
|
- `GET /staff/shifts/open` returns opportunities, not assignments
|
||||||
|
- `GET /staff/orders/available` returns grouped order opportunities for booking
|
||||||
- `GET /staff/shifts/assigned` returns active assigned shifts
|
- `GET /staff/shifts/assigned` returns active assigned shifts
|
||||||
- `GET /client/orders/view` is the timeline/read model for client
|
- `GET /client/shifts/scheduled` is the canonical timeline/read model for client
|
||||||
|
- `GET /client/orders/view` is now a deprecated compatibility alias
|
||||||
- `POST /client/orders/:orderId/edit` and `POST /client/orders/:orderId/cancel` apply to future shifts only
|
- `POST /client/orders/:orderId/edit` and `POST /client/orders/:orderId/cancel` apply to future shifts only
|
||||||
|
|
||||||
## 5) Client app screen mapping
|
## 5) Client app screen mapping
|
||||||
@@ -165,7 +168,8 @@ Swap management flow:
|
|||||||
|
|
||||||
### Orders
|
### Orders
|
||||||
|
|
||||||
- `GET /client/orders/view`
|
- `GET /client/shifts/scheduled`
|
||||||
|
- `GET /client/orders/view` deprecated alias
|
||||||
- `GET /client/orders/:orderId/reorder-preview`
|
- `GET /client/orders/:orderId/reorder-preview`
|
||||||
- `POST /client/orders/one-time`
|
- `POST /client/orders/one-time`
|
||||||
- `POST /client/orders/recurring`
|
- `POST /client/orders/recurring`
|
||||||
@@ -230,12 +234,17 @@ Important:
|
|||||||
|
|
||||||
### Find shifts
|
### Find shifts
|
||||||
|
|
||||||
|
- `GET /staff/orders/available`
|
||||||
|
- `POST /staff/orders/:orderId/book`
|
||||||
- `GET /staff/shifts/open`
|
- `GET /staff/shifts/open`
|
||||||
- `POST /staff/shifts/:shiftId/apply`
|
- `POST /staff/shifts/:shiftId/apply`
|
||||||
|
|
||||||
Rule:
|
Rule:
|
||||||
|
|
||||||
- use `roleId` from the open-shifts response
|
- use `roleId` from the order-available response when booking an order
|
||||||
|
- that `roleId` is the role catalog id for the grouped order booking flow
|
||||||
|
- use `roleId` from the open-shifts response only for shift-level apply
|
||||||
|
- that `roleId` is the concrete `shift_roles.id`
|
||||||
|
|
||||||
### My shifts
|
### My shifts
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ This is the shortest path for frontend to implement the v2 mobile clients agains
|
|||||||
|
|
||||||
Base URL:
|
Base URL:
|
||||||
|
|
||||||
- `https://krow-api-v2-933560802882.us-central1.run.app`
|
- `https://krow-api-v2-e3g6witsvq-uc.a.run.app`
|
||||||
|
|
||||||
Use this doc together with:
|
Use this doc together with:
|
||||||
|
|
||||||
@@ -30,7 +30,10 @@ Important consequences:
|
|||||||
|
|
||||||
- `GET /staff/shifts/open` returns open shift-role opportunities.
|
- `GET /staff/shifts/open` returns open shift-role opportunities.
|
||||||
- `POST /staff/shifts/:shiftId/apply` must send the `roleId` from that response.
|
- `POST /staff/shifts/:shiftId/apply` must send the `roleId` from that response.
|
||||||
- `GET /client/orders/view` is the timeline/read model for the client app.
|
- `GET /staff/orders/available` returns grouped order opportunities for atomic booking.
|
||||||
|
- `POST /staff/orders/:orderId/book` must send the `roleId` from that response.
|
||||||
|
- `GET /client/shifts/scheduled` is the canonical timeline/read model for the client app.
|
||||||
|
- `GET /client/orders/view` is a deprecated compatibility alias.
|
||||||
- `POST /client/orders/:orderId/edit` and `POST /client/orders/:orderId/cancel` only affect future shifts.
|
- `POST /client/orders/:orderId/edit` and `POST /client/orders/:orderId/cancel` only affect future shifts.
|
||||||
|
|
||||||
## 3) Auth implementation
|
## 3) Auth implementation
|
||||||
@@ -122,7 +125,8 @@ Dispatch-priority rule:
|
|||||||
|
|
||||||
### Orders
|
### Orders
|
||||||
|
|
||||||
- `GET /client/orders/view`
|
- `GET /client/shifts/scheduled`
|
||||||
|
- `GET /client/orders/view` deprecated alias
|
||||||
- `GET /client/orders/:orderId/reorder-preview`
|
- `GET /client/orders/:orderId/reorder-preview`
|
||||||
- `POST /client/orders/one-time`
|
- `POST /client/orders/one-time`
|
||||||
- `POST /client/orders/recurring`
|
- `POST /client/orders/recurring`
|
||||||
@@ -175,13 +179,17 @@ Rapid-order flow:
|
|||||||
|
|
||||||
### Find shifts
|
### Find shifts
|
||||||
|
|
||||||
|
- `GET /staff/orders/available`
|
||||||
|
- `POST /staff/orders/:orderId/book`
|
||||||
- `GET /staff/shifts/open`
|
- `GET /staff/shifts/open`
|
||||||
- `POST /staff/shifts/:shiftId/apply`
|
- `POST /staff/shifts/:shiftId/apply`
|
||||||
|
|
||||||
Rule:
|
Rule:
|
||||||
|
|
||||||
- send the `roleId` from the open-shifts response
|
- send the `roleId` from the order-available response when booking an order
|
||||||
- this is the concrete `shift_roles.id`
|
- this `roleId` is the role catalog id for grouped order booking
|
||||||
|
- send the `roleId` from the open-shifts response only when applying to one shift
|
||||||
|
- that route still uses the concrete `shift_roles.id`
|
||||||
|
|
||||||
### My shifts
|
### My shifts
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ This document is the frontend handoff for the `staff/shifts/*` routes on the uni
|
|||||||
|
|
||||||
Base URL:
|
Base URL:
|
||||||
|
|
||||||
- `https://krow-api-v2-933560802882.us-central1.run.app`
|
- `https://krow-api-v2-e3g6witsvq-uc.a.run.app`
|
||||||
|
|
||||||
## Read routes
|
## Read routes
|
||||||
|
|
||||||
|
- `GET /staff/orders/available`
|
||||||
- `GET /staff/shifts/assigned`
|
- `GET /staff/shifts/assigned`
|
||||||
- `GET /staff/shifts/open`
|
- `GET /staff/shifts/open`
|
||||||
- `GET /staff/shifts/pending`
|
- `GET /staff/shifts/pending`
|
||||||
@@ -17,6 +18,7 @@ Base URL:
|
|||||||
|
|
||||||
## Write routes
|
## Write routes
|
||||||
|
|
||||||
|
- `POST /staff/orders/:orderId/book`
|
||||||
- `POST /staff/shifts/:shiftId/apply`
|
- `POST /staff/shifts/:shiftId/apply`
|
||||||
- `POST /staff/shifts/:shiftId/accept`
|
- `POST /staff/shifts/:shiftId/accept`
|
||||||
- `POST /staff/shifts/:shiftId/decline`
|
- `POST /staff/shifts/:shiftId/decline`
|
||||||
@@ -30,6 +32,68 @@ All write routes require:
|
|||||||
|
|
||||||
## Shift lifecycle
|
## Shift lifecycle
|
||||||
|
|
||||||
|
### Find work by order
|
||||||
|
|
||||||
|
`GET /staff/orders/available`
|
||||||
|
|
||||||
|
- use this for grouped recurring or permanent work cards
|
||||||
|
- each item represents one order plus one role
|
||||||
|
- this feed is already filtered to the current worker context
|
||||||
|
- `schedule` gives the preview for the whole booking window
|
||||||
|
|
||||||
|
Example response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"orderId": "uuid",
|
||||||
|
"orderType": "RECURRING",
|
||||||
|
"roleId": "uuid",
|
||||||
|
"roleCode": "BARISTA",
|
||||||
|
"roleName": "Barista",
|
||||||
|
"clientName": "Google Mountain View Cafes",
|
||||||
|
"location": "Google MV Cafe Clock Point",
|
||||||
|
"locationAddress": "1600 Amphitheatre Pkwy, Mountain View, CA",
|
||||||
|
"hourlyRateCents": 2300,
|
||||||
|
"hourlyRate": 23,
|
||||||
|
"requiredWorkerCount": 1,
|
||||||
|
"filledCount": 0,
|
||||||
|
"instantBook": false,
|
||||||
|
"dispatchTeam": "CORE",
|
||||||
|
"dispatchPriority": 1,
|
||||||
|
"schedule": {
|
||||||
|
"totalShifts": 3,
|
||||||
|
"startDate": "2026-03-24",
|
||||||
|
"endDate": "2026-03-28",
|
||||||
|
"daysOfWeek": ["WED", "FRI"],
|
||||||
|
"startTime": "09:00",
|
||||||
|
"endTime": "15:00",
|
||||||
|
"timezone": "America/Los_Angeles",
|
||||||
|
"firstShiftStartsAt": "2026-03-25T16:00:00.000Z",
|
||||||
|
"lastShiftEndsAt": "2026-03-27T22:00:00.000Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`POST /staff/orders/:orderId/book`
|
||||||
|
|
||||||
|
- use this when the worker books the full order instead of one shift
|
||||||
|
- booking is atomic across the future shifts in that order for the selected role
|
||||||
|
- backend returns `PENDING` when the booking is reserved but not instant-booked
|
||||||
|
- backend returns `CONFIRMED` when every future shift in that booking path is instant-booked
|
||||||
|
|
||||||
|
Example request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"roleId": "uuid"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Important:
|
||||||
|
|
||||||
|
- `roleId` for the order-booking flow is the role catalog id returned by `GET /staff/orders/available`
|
||||||
|
- it is not the same thing as the per-shift `shift_roles.id`
|
||||||
|
|
||||||
### Find shifts
|
### Find shifts
|
||||||
|
|
||||||
`GET /staff/shifts/open`
|
`GET /staff/shifts/open`
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Frontend should use this service as the single base URL:
|
Frontend should use this service as the single base URL:
|
||||||
|
|
||||||
- `https://krow-api-v2-933560802882.us-central1.run.app`
|
- `https://krow-api-v2-e3g6witsvq-uc.a.run.app`
|
||||||
|
|
||||||
The gateway keeps backend services separate internally, but frontend should treat it as one API.
|
The gateway keeps backend services separate internally, but frontend should treat it as one API.
|
||||||
|
|
||||||
@@ -54,7 +54,8 @@ Full auth behavior, including staff phone flow and refresh rules, is documented
|
|||||||
- `GET /client/vendors/:vendorId/roles`
|
- `GET /client/vendors/:vendorId/roles`
|
||||||
- `GET /client/hubs/:hubId/managers`
|
- `GET /client/hubs/:hubId/managers`
|
||||||
- `GET /client/team-members`
|
- `GET /client/team-members`
|
||||||
- `GET /client/orders/view`
|
- `GET /client/shifts/scheduled`
|
||||||
|
- `GET /client/orders/view` deprecated compatibility alias
|
||||||
- `GET /client/orders/:orderId/reorder-preview`
|
- `GET /client/orders/:orderId/reorder-preview`
|
||||||
- `GET /client/reports/summary`
|
- `GET /client/reports/summary`
|
||||||
- `GET /client/reports/daily-ops`
|
- `GET /client/reports/daily-ops`
|
||||||
@@ -88,6 +89,12 @@ Full auth behavior, including staff phone flow and refresh rules, is documented
|
|||||||
- `POST /client/coverage/dispatch-teams/memberships`
|
- `POST /client/coverage/dispatch-teams/memberships`
|
||||||
- `DELETE /client/coverage/dispatch-teams/memberships/:membershipId`
|
- `DELETE /client/coverage/dispatch-teams/memberships/:membershipId`
|
||||||
|
|
||||||
|
Timeline route naming:
|
||||||
|
|
||||||
|
- `GET /client/shifts/scheduled` is the canonical client timeline route
|
||||||
|
- it returns shift-level scheduled items, not order headers
|
||||||
|
- `GET /client/orders/view` still returns the same payload for compatibility, but now emits a deprecation header
|
||||||
|
|
||||||
Coverage-review request payload may also send:
|
Coverage-review request payload may also send:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -176,6 +183,7 @@ The manager is created as an invited business membership. If `hubId` is present,
|
|||||||
- `GET /staff/payments/summary`
|
- `GET /staff/payments/summary`
|
||||||
- `GET /staff/payments/history`
|
- `GET /staff/payments/history`
|
||||||
- `GET /staff/payments/chart`
|
- `GET /staff/payments/chart`
|
||||||
|
- `GET /staff/orders/available`
|
||||||
- `GET /staff/shifts/assigned`
|
- `GET /staff/shifts/assigned`
|
||||||
- `GET /staff/shifts/open`
|
- `GET /staff/shifts/open`
|
||||||
- `GET /staff/shifts/pending`
|
- `GET /staff/shifts/pending`
|
||||||
@@ -239,6 +247,14 @@ Example `GET /staff/profile/stats` response:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Order booking route notes:
|
||||||
|
|
||||||
|
- `GET /staff/orders/available` is the canonical order-level marketplace feed for recurring and grouped work
|
||||||
|
- `GET /staff/shifts/open` remains available for shift-level opportunities and swap coverage
|
||||||
|
- `POST /staff/orders/:orderId/book` books the future shifts of an order atomically for one role
|
||||||
|
- the `roleId` returned by `GET /staff/orders/available` is the role catalog id for the order booking flow
|
||||||
|
- the `roleId` returned by `GET /staff/shifts/open` is still the concrete `shift_roles.id` for shift-level apply
|
||||||
|
|
||||||
### Staff writes
|
### Staff writes
|
||||||
|
|
||||||
- `POST /staff/profile/setup`
|
- `POST /staff/profile/setup`
|
||||||
@@ -249,6 +265,7 @@ Example `GET /staff/profile/stats` response:
|
|||||||
- `POST /staff/location-streams`
|
- `POST /staff/location-streams`
|
||||||
- `PUT /staff/availability`
|
- `PUT /staff/availability`
|
||||||
- `POST /staff/availability/quick-set`
|
- `POST /staff/availability/quick-set`
|
||||||
|
- `POST /staff/orders/:orderId/book`
|
||||||
- `POST /staff/shifts/:shiftId/apply`
|
- `POST /staff/shifts/:shiftId/apply`
|
||||||
- `POST /staff/shifts/:shiftId/accept`
|
- `POST /staff/shifts/:shiftId/accept`
|
||||||
- `POST /staff/shifts/:shiftId/decline`
|
- `POST /staff/shifts/:shiftId/decline`
|
||||||
|
|||||||
Reference in New Issue
Block a user