Merge pull request #649 from Oloodi/codex/feat-backend-v2-foundation

feat(backend): add v2 foundation stack and frontend migration docs
This commit is contained in:
Achintha Isuru
2026-03-12 14:18:41 -04:00
committed by GitHub
60 changed files with 11341 additions and 681 deletions

View File

@@ -9,7 +9,10 @@
"scripts": {
"start": "node src/server.js",
"test": "node --test",
"migrate:idempotency": "node scripts/migrate-idempotency.mjs"
"migrate:idempotency": "node scripts/migrate-idempotency.mjs",
"migrate:v2-schema": "node scripts/migrate-v2-schema.mjs",
"seed:v2-demo": "node scripts/seed-v2-demo-data.mjs",
"smoke:v2-live": "node scripts/live-smoke-v2.mjs"
},
"dependencies": {
"express": "^4.21.2",

View File

@@ -0,0 +1,348 @@
import assert from 'node:assert/strict';
import { V2DemoFixture as fixture } from './v2-demo-fixture.mjs';
const firebaseApiKey = process.env.FIREBASE_API_KEY || 'AIzaSyBqRtZPMGU-Sz5x5UnRrunKu5NSWYyPRn8';
const demoEmail = process.env.V2_SMOKE_EMAIL || fixture.users.businessOwner.email;
const demoPassword = process.env.V2_SMOKE_PASSWORD || 'Demo2026!';
const commandBaseUrl = process.env.COMMAND_API_BASE_URL || 'https://krow-command-api-v2-e3g6witsvq-uc.a.run.app';
const queryBaseUrl = process.env.QUERY_API_BASE_URL || 'https://krow-query-api-v2-e3g6witsvq-uc.a.run.app';
async function signInWithPassword() {
const response = await fetch(
`https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=${firebaseApiKey}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: demoEmail,
password: demoPassword,
returnSecureToken: true,
}),
}
);
const payload = await response.json();
if (!response.ok) {
throw new Error(`Firebase sign-in failed: ${JSON.stringify(payload)}`);
}
return {
idToken: payload.idToken,
localId: payload.localId,
};
}
async function apiCall(baseUrl, path, {
method = 'GET',
token,
idempotencyKey,
body,
expectedStatus = 200,
} = {}) {
const headers = {};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
if (idempotencyKey) {
headers['Idempotency-Key'] = idempotencyKey;
}
if (body !== undefined) {
headers['Content-Type'] = 'application/json';
}
const response = await fetch(`${baseUrl}${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await response.text();
const payload = text ? JSON.parse(text) : {};
if (response.status !== expectedStatus) {
throw new Error(`${method} ${path} expected ${expectedStatus}, got ${response.status}: ${text}`);
}
return payload;
}
function uniqueKey(prefix) {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
function logStep(step, payload) {
// eslint-disable-next-line no-console
console.log(`[live-smoke-v2] ${step}: ${JSON.stringify(payload)}`);
}
async function main() {
const auth = await signInWithPassword();
assert.equal(auth.localId, fixture.users.businessOwner.id);
logStep('auth.ok', { uid: auth.localId, email: demoEmail });
const listOrders = await apiCall(
queryBaseUrl,
`/query/tenants/${fixture.tenant.id}/orders`,
{ token: auth.idToken }
);
assert.ok(Array.isArray(listOrders.items));
assert.ok(listOrders.items.some((item) => item.id === fixture.orders.open.id));
logStep('orders.list.ok', { count: listOrders.items.length });
const openOrderDetail = await apiCall(
queryBaseUrl,
`/query/tenants/${fixture.tenant.id}/orders/${fixture.orders.open.id}`,
{ token: auth.idToken }
);
assert.equal(openOrderDetail.id, fixture.orders.open.id);
assert.equal(openOrderDetail.shifts[0].id, fixture.shifts.open.id);
logStep('orders.detail.ok', { orderId: openOrderDetail.id, shiftCount: openOrderDetail.shifts.length });
const favoriteResult = await apiCall(
commandBaseUrl,
`/commands/businesses/${fixture.business.id}/favorite-staff`,
{
method: 'POST',
token: auth.idToken,
idempotencyKey: uniqueKey('favorite'),
body: {
tenantId: fixture.tenant.id,
staffId: fixture.staff.ana.id,
},
}
);
assert.equal(favoriteResult.staffId, fixture.staff.ana.id);
logStep('favorites.add.ok', favoriteResult);
const favoriteList = await apiCall(
queryBaseUrl,
`/query/tenants/${fixture.tenant.id}/businesses/${fixture.business.id}/favorite-staff`,
{ token: auth.idToken }
);
assert.ok(favoriteList.items.some((item) => item.staffId === fixture.staff.ana.id));
logStep('favorites.list.ok', { count: favoriteList.items.length });
const reviewResult = await apiCall(
commandBaseUrl,
`/commands/assignments/${fixture.assignments.completedAna.id}/reviews`,
{
method: 'POST',
token: auth.idToken,
idempotencyKey: uniqueKey('review'),
body: {
tenantId: fixture.tenant.id,
businessId: fixture.business.id,
staffId: fixture.staff.ana.id,
rating: 5,
reviewText: 'Live smoke review',
tags: ['smoke', 'reliable'],
},
}
);
assert.equal(reviewResult.staffId, fixture.staff.ana.id);
logStep('reviews.create.ok', reviewResult);
const reviewSummary = await apiCall(
queryBaseUrl,
`/query/tenants/${fixture.tenant.id}/staff/${fixture.staff.ana.id}/review-summary`,
{ token: auth.idToken }
);
assert.equal(reviewSummary.staffId, fixture.staff.ana.id);
assert.ok(reviewSummary.ratingCount >= 1);
logStep('reviews.summary.ok', { ratingCount: reviewSummary.ratingCount, averageRating: reviewSummary.averageRating });
const assigned = await apiCall(
commandBaseUrl,
`/commands/shifts/${fixture.shifts.open.id}/assign-staff`,
{
method: 'POST',
token: auth.idToken,
idempotencyKey: uniqueKey('assign'),
body: {
tenantId: fixture.tenant.id,
shiftRoleId: fixture.shiftRoles.openBarista.id,
workforceId: fixture.workforce.ana.id,
applicationId: fixture.applications.openAna.id,
},
}
);
assert.equal(assigned.shiftId, fixture.shifts.open.id);
logStep('assign.ok', assigned);
const accepted = await apiCall(
commandBaseUrl,
`/commands/shifts/${fixture.shifts.open.id}/accept`,
{
method: 'POST',
token: auth.idToken,
idempotencyKey: uniqueKey('accept'),
body: {
shiftRoleId: fixture.shiftRoles.openBarista.id,
workforceId: fixture.workforce.ana.id,
},
}
);
assert.ok(['ASSIGNED', 'ACCEPTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED'].includes(accepted.status));
const liveAssignmentId = accepted.assignmentId || assigned.assignmentId;
logStep('accept.ok', accepted);
const clockIn = await apiCall(
commandBaseUrl,
'/commands/attendance/clock-in',
{
method: 'POST',
token: auth.idToken,
idempotencyKey: uniqueKey('clockin'),
body: {
assignmentId: liveAssignmentId,
sourceType: 'NFC',
sourceReference: 'smoke',
nfcTagUid: fixture.clockPoint.nfcTagUid,
deviceId: 'smoke-device',
latitude: fixture.clockPoint.latitude,
longitude: fixture.clockPoint.longitude,
accuracyMeters: 5,
},
}
);
assert.equal(clockIn.assignmentId, liveAssignmentId);
logStep('attendance.clockin.ok', clockIn);
const clockOut = await apiCall(
commandBaseUrl,
'/commands/attendance/clock-out',
{
method: 'POST',
token: auth.idToken,
idempotencyKey: uniqueKey('clockout'),
body: {
assignmentId: liveAssignmentId,
sourceType: 'NFC',
sourceReference: 'smoke',
nfcTagUid: fixture.clockPoint.nfcTagUid,
deviceId: 'smoke-device',
latitude: fixture.clockPoint.latitude,
longitude: fixture.clockPoint.longitude,
accuracyMeters: 5,
},
}
);
assert.equal(clockOut.assignmentId, liveAssignmentId);
logStep('attendance.clockout.ok', clockOut);
const attendance = await apiCall(
queryBaseUrl,
`/query/tenants/${fixture.tenant.id}/assignments/${liveAssignmentId}/attendance`,
{ token: auth.idToken }
);
assert.ok(Array.isArray(attendance.events));
assert.ok(attendance.events.length >= 2);
logStep('attendance.query.ok', { eventCount: attendance.events.length, sessionStatus: attendance.sessionStatus });
const orderNumber = `ORD-V2-SMOKE-${Date.now()}`;
const createdOrder = await apiCall(
commandBaseUrl,
'/commands/orders/create',
{
method: 'POST',
token: auth.idToken,
idempotencyKey: uniqueKey('order-create'),
body: {
tenantId: fixture.tenant.id,
businessId: fixture.business.id,
vendorId: fixture.vendor.id,
orderNumber,
title: 'Smoke created order',
serviceType: 'EVENT',
shifts: [
{
shiftCode: `SHIFT-${Date.now()}`,
title: 'Smoke shift',
startsAt: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(),
endsAt: new Date(Date.now() + 6 * 60 * 60 * 1000).toISOString(),
requiredWorkers: 1,
clockPointId: fixture.clockPoint.id,
roles: [
{
roleCode: fixture.roles.barista.code,
roleName: fixture.roles.barista.name,
workersNeeded: 1,
payRateCents: 2200,
billRateCents: 3500,
},
],
},
],
},
}
);
assert.equal(createdOrder.orderNumber, orderNumber);
logStep('orders.create.ok', createdOrder);
const updatedOrder = await apiCall(
commandBaseUrl,
`/commands/orders/${createdOrder.orderId}/update`,
{
method: 'POST',
token: auth.idToken,
idempotencyKey: uniqueKey('order-update'),
body: {
tenantId: fixture.tenant.id,
title: 'Smoke updated order',
notes: 'updated during live smoke',
},
}
);
assert.equal(updatedOrder.orderId, createdOrder.orderId);
logStep('orders.update.ok', updatedOrder);
const changedShift = await apiCall(
commandBaseUrl,
`/commands/shifts/${createdOrder.shiftIds[0]}/change-status`,
{
method: 'POST',
token: auth.idToken,
idempotencyKey: uniqueKey('shift-status'),
body: {
tenantId: fixture.tenant.id,
status: 'PENDING_CONFIRMATION',
reason: 'live smoke transition',
},
}
);
assert.equal(changedShift.status, 'PENDING_CONFIRMATION');
logStep('shift.status.ok', changedShift);
const cancelledOrder = await apiCall(
commandBaseUrl,
`/commands/orders/${createdOrder.orderId}/cancel`,
{
method: 'POST',
token: auth.idToken,
idempotencyKey: uniqueKey('order-cancel'),
body: {
tenantId: fixture.tenant.id,
reason: 'live smoke cleanup',
},
}
);
assert.equal(cancelledOrder.status, 'CANCELLED');
logStep('orders.cancel.ok', cancelledOrder);
const cancelledOrderDetail = await apiCall(
queryBaseUrl,
`/query/tenants/${fixture.tenant.id}/orders/${createdOrder.orderId}`,
{ token: auth.idToken }
);
assert.equal(cancelledOrderDetail.status, 'CANCELLED');
logStep('orders.cancel.verify.ok', { orderId: cancelledOrderDetail.id, status: cancelledOrderDetail.status });
// eslint-disable-next-line no-console
console.log('LIVE_SMOKE_V2_OK');
}
main().catch((error) => {
// eslint-disable-next-line no-console
console.error(error);
process.exit(1);
});

View File

@@ -3,11 +3,11 @@ import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Pool } from 'pg';
const databaseUrl = process.env.IDEMPOTENCY_DATABASE_URL;
const databaseUrl = process.env.IDEMPOTENCY_DATABASE_URL || process.env.DATABASE_URL;
if (!databaseUrl) {
// eslint-disable-next-line no-console
console.error('IDEMPOTENCY_DATABASE_URL is required');
console.error('IDEMPOTENCY_DATABASE_URL or DATABASE_URL is required');
process.exit(1);
}

View File

@@ -0,0 +1,69 @@
import { readdirSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Pool } from 'pg';
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
// eslint-disable-next-line no-console
console.error('DATABASE_URL is required');
process.exit(1);
}
const scriptDir = resolve(fileURLToPath(new URL('.', import.meta.url)));
const migrationsDir = resolve(scriptDir, '../sql/v2');
const migrationFiles = readdirSync(migrationsDir)
.filter((file) => file.endsWith('.sql'))
.sort();
const pool = new Pool({
connectionString: databaseUrl,
max: Number.parseInt(process.env.DB_POOL_MAX || '5', 10),
});
async function ensureMigrationTable(client) {
await client.query(`
CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
`);
}
try {
const client = await pool.connect();
try {
await client.query('BEGIN');
await ensureMigrationTable(client);
for (const file of migrationFiles) {
const alreadyApplied = await client.query(
'SELECT 1 FROM schema_migrations WHERE version = $1',
[file]
);
if (alreadyApplied.rowCount > 0) {
continue;
}
const sql = readFileSync(resolve(migrationsDir, file), 'utf8');
await client.query(sql);
await client.query(
'INSERT INTO schema_migrations (version) VALUES ($1)',
[file]
);
// eslint-disable-next-line no-console
console.log(`Applied migration ${file}`);
}
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
} finally {
await pool.end();
}

View File

@@ -0,0 +1,600 @@
import { Pool } from 'pg';
import { resolveDatabasePoolConfig } from '../src/services/db.js';
import { V2DemoFixture as fixture } from './v2-demo-fixture.mjs';
const poolConfig = resolveDatabasePoolConfig();
if (!poolConfig) {
// eslint-disable-next-line no-console
console.error('Database connection settings are required');
process.exit(1);
}
const pool = new Pool(poolConfig);
function hoursFromNow(hours) {
return new Date(Date.now() + (hours * 60 * 60 * 1000)).toISOString();
}
async function upsertUser(client, user) {
await client.query(
`
INSERT INTO users (id, email, display_name, status, metadata)
VALUES ($1, $2, $3, 'ACTIVE', '{}'::jsonb)
ON CONFLICT (id) DO UPDATE
SET email = EXCLUDED.email,
display_name = EXCLUDED.display_name,
status = 'ACTIVE',
updated_at = NOW()
`,
[user.id, user.email || null, user.displayName || null]
);
}
async function main() {
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('DELETE FROM tenants WHERE id = $1', [fixture.tenant.id]);
const openStartsAt = hoursFromNow(4);
const openEndsAt = hoursFromNow(12);
const completedStartsAt = hoursFromNow(-28);
const completedEndsAt = hoursFromNow(-20);
const checkedInAt = hoursFromNow(-27.5);
const checkedOutAt = hoursFromNow(-20.25);
const invoiceDueAt = hoursFromNow(72);
await upsertUser(client, fixture.users.businessOwner);
await upsertUser(client, fixture.users.operationsManager);
await upsertUser(client, fixture.users.vendorManager);
await client.query(
`
INSERT INTO tenants (id, slug, name, status, metadata)
VALUES ($1, $2, $3, 'ACTIVE', $4::jsonb)
`,
[fixture.tenant.id, fixture.tenant.slug, fixture.tenant.name, JSON.stringify({ seededBy: 'seed-v2-demo-data' })]
);
await client.query(
`
INSERT INTO tenant_memberships (tenant_id, user_id, membership_status, base_role, metadata)
VALUES
($1, $2, 'ACTIVE', 'admin', '{"persona":"business_owner"}'::jsonb),
($1, $3, 'ACTIVE', 'manager', '{"persona":"ops_manager"}'::jsonb),
($1, $4, 'ACTIVE', 'manager', '{"persona":"vendor_manager"}'::jsonb)
`,
[
fixture.tenant.id,
fixture.users.businessOwner.id,
fixture.users.operationsManager.id,
fixture.users.vendorManager.id,
]
);
await client.query(
`
INSERT INTO businesses (
id, tenant_id, slug, business_name, status, contact_name, contact_email, contact_phone, metadata
)
VALUES ($1, $2, $3, $4, 'ACTIVE', $5, $6, $7, $8::jsonb)
`,
[
fixture.business.id,
fixture.tenant.id,
fixture.business.slug,
fixture.business.name,
'Legendary Client Manager',
fixture.users.businessOwner.email,
'+15550001001',
JSON.stringify({ segment: 'buyer', seeded: true }),
]
);
await client.query(
`
INSERT INTO business_memberships (
tenant_id, business_id, user_id, membership_status, business_role, metadata
)
VALUES
($1, $2, $3, 'ACTIVE', 'owner', '{"persona":"client_owner"}'::jsonb),
($1, $2, $4, 'ACTIVE', 'manager', '{"persona":"client_ops"}'::jsonb)
`,
[fixture.tenant.id, fixture.business.id, fixture.users.businessOwner.id, fixture.users.operationsManager.id]
);
await client.query(
`
INSERT INTO vendors (
id, tenant_id, slug, company_name, status, contact_name, contact_email, contact_phone, metadata
)
VALUES ($1, $2, $3, $4, 'ACTIVE', $5, $6, $7, $8::jsonb)
`,
[
fixture.vendor.id,
fixture.tenant.id,
fixture.vendor.slug,
fixture.vendor.name,
'Vendor Manager',
fixture.users.vendorManager.email,
'+15550001002',
JSON.stringify({ kind: 'internal_pool', seeded: true }),
]
);
await client.query(
`
INSERT INTO vendor_memberships (
tenant_id, vendor_id, user_id, membership_status, vendor_role, metadata
)
VALUES ($1, $2, $3, 'ACTIVE', 'owner', '{"persona":"vendor_owner"}'::jsonb)
`,
[fixture.tenant.id, fixture.vendor.id, fixture.users.vendorManager.id]
);
await client.query(
`
INSERT INTO roles_catalog (id, tenant_id, code, name, status, metadata)
VALUES
($1, $3, $4, $5, 'ACTIVE', '{}'::jsonb),
($2, $3, $6, $7, 'ACTIVE', '{}'::jsonb)
`,
[
fixture.roles.barista.id,
fixture.roles.captain.id,
fixture.tenant.id,
fixture.roles.barista.code,
fixture.roles.barista.name,
fixture.roles.captain.code,
fixture.roles.captain.name,
]
);
await client.query(
`
INSERT INTO staffs (
id, tenant_id, user_id, full_name, email, phone, status, primary_role, onboarding_status,
average_rating, rating_count, metadata
)
VALUES ($1, $2, NULL, $3, $4, $5, 'ACTIVE', $6, 'COMPLETED', 4.50, 1, $7::jsonb)
`,
[
fixture.staff.ana.id,
fixture.tenant.id,
fixture.staff.ana.fullName,
fixture.staff.ana.email,
fixture.staff.ana.phone,
fixture.staff.ana.primaryRole,
JSON.stringify({ favoriteCandidate: true, seeded: true }),
]
);
await client.query(
`
INSERT INTO staff_roles (staff_id, role_id, is_primary)
VALUES ($1, $2, TRUE)
`,
[fixture.staff.ana.id, fixture.roles.barista.id]
);
await client.query(
`
INSERT INTO workforce (id, tenant_id, vendor_id, staff_id, workforce_number, employment_type, status, metadata)
VALUES ($1, $2, $3, $4, $5, 'TEMP', 'ACTIVE', $6::jsonb)
`,
[
fixture.workforce.ana.id,
fixture.tenant.id,
fixture.vendor.id,
fixture.staff.ana.id,
fixture.workforce.ana.workforceNumber,
JSON.stringify({ source: 'seed-v2-demo' }),
]
);
await client.query(
`
INSERT INTO clock_points (
id, tenant_id, business_id, label, address, latitude, longitude, geofence_radius_meters, nfc_tag_uid, status, metadata
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'ACTIVE', '{}'::jsonb)
`,
[
fixture.clockPoint.id,
fixture.tenant.id,
fixture.business.id,
fixture.clockPoint.label,
fixture.clockPoint.address,
fixture.clockPoint.latitude,
fixture.clockPoint.longitude,
fixture.clockPoint.geofenceRadiusMeters,
fixture.clockPoint.nfcTagUid,
]
);
await client.query(
`
INSERT INTO orders (
id, tenant_id, business_id, vendor_id, order_number, title, description, status, service_type,
starts_at, ends_at, location_name, location_address, latitude, longitude, notes, created_by_user_id, metadata
)
VALUES
($1, $3, $4, $5, $6, $7, 'Open order for live v2 commands', 'OPEN', 'EVENT', $8, $9, 'Google Cafe', $10, $11, $12, 'Use this order for live smoke and frontend reads', $13, '{"slice":"open"}'::jsonb),
($2, $3, $4, $5, $14, $15, 'Completed order for favorites, reviews, invoices, and attendance history', 'COMPLETED', 'CATERING', $16, $17, 'Google Catering', $10, $11, $12, 'Completed historical example', $13, '{"slice":"completed"}'::jsonb)
`,
[
fixture.orders.open.id,
fixture.orders.completed.id,
fixture.tenant.id,
fixture.business.id,
fixture.vendor.id,
fixture.orders.open.number,
fixture.orders.open.title,
openStartsAt,
openEndsAt,
fixture.clockPoint.address,
fixture.clockPoint.latitude,
fixture.clockPoint.longitude,
fixture.users.businessOwner.id,
fixture.orders.completed.number,
fixture.orders.completed.title,
completedStartsAt,
completedEndsAt,
]
);
await client.query(
`
INSERT INTO shifts (
id, tenant_id, order_id, business_id, vendor_id, clock_point_id, shift_code, title, status, starts_at, ends_at, timezone,
location_name, location_address, latitude, longitude, geofence_radius_meters, required_workers, assigned_workers, notes, metadata
)
VALUES
($1, $3, $5, $7, $9, $11, $13, $15, 'OPEN', $17, $18, 'America/Los_Angeles', 'Google Cafe', $19, $21, $22, $23, 1, 0, 'Open staffing need', '{"slice":"open"}'::jsonb),
($2, $4, $6, $8, $10, $12, $14, $16, 'COMPLETED', $20, $24, 'America/Los_Angeles', 'Google Catering', $19, $21, $22, $23, 1, 1, 'Completed staffed shift', '{"slice":"completed"}'::jsonb)
`,
[
fixture.shifts.open.id,
fixture.shifts.completed.id,
fixture.tenant.id,
fixture.tenant.id,
fixture.orders.open.id,
fixture.orders.completed.id,
fixture.business.id,
fixture.business.id,
fixture.vendor.id,
fixture.vendor.id,
fixture.clockPoint.id,
fixture.clockPoint.id,
fixture.shifts.open.code,
fixture.shifts.completed.code,
fixture.shifts.open.title,
fixture.shifts.completed.title,
openStartsAt,
openEndsAt,
fixture.clockPoint.address,
completedStartsAt,
fixture.clockPoint.latitude,
fixture.clockPoint.longitude,
fixture.clockPoint.geofenceRadiusMeters,
completedEndsAt,
]
);
await client.query(
`
INSERT INTO shift_roles (
id, shift_id, role_id, role_code, role_name, workers_needed, assigned_count, pay_rate_cents, bill_rate_cents, metadata
)
VALUES
($1, $2, $3, $4, $5, 1, 0, 2200, 3500, '{"slice":"open"}'::jsonb),
($6, $7, $3, $4, $5, 1, 1, 2200, 3500, '{"slice":"completed"}'::jsonb)
`,
[
fixture.shiftRoles.openBarista.id,
fixture.shifts.open.id,
fixture.roles.barista.id,
fixture.roles.barista.code,
fixture.roles.barista.name,
fixture.shiftRoles.completedBarista.id,
fixture.shifts.completed.id,
]
);
await client.query(
`
INSERT INTO applications (
id, tenant_id, shift_id, shift_role_id, staff_id, status, origin, applied_at, metadata
)
VALUES ($1, $2, $3, $4, $5, 'PENDING', 'STAFF', NOW(), '{"slice":"open"}'::jsonb)
`,
[
fixture.applications.openAna.id,
fixture.tenant.id,
fixture.shifts.open.id,
fixture.shiftRoles.openBarista.id,
fixture.staff.ana.id,
]
);
await client.query(
`
INSERT INTO assignments (
id, tenant_id, business_id, vendor_id, shift_id, shift_role_id, workforce_id, staff_id, status,
assigned_at, accepted_at, checked_in_at, checked_out_at, metadata
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'COMPLETED', $9, $10, $11, $12, '{"slice":"completed"}'::jsonb)
`,
[
fixture.assignments.completedAna.id,
fixture.tenant.id,
fixture.business.id,
fixture.vendor.id,
fixture.shifts.completed.id,
fixture.shiftRoles.completedBarista.id,
fixture.workforce.ana.id,
fixture.staff.ana.id,
completedStartsAt,
completedStartsAt,
checkedInAt,
checkedOutAt,
]
);
await client.query(
`
INSERT INTO attendance_events (
tenant_id, assignment_id, shift_id, staff_id, clock_point_id, event_type, source_type, source_reference,
nfc_tag_uid, device_id, latitude, longitude, accuracy_meters, distance_to_clock_point_meters, within_geofence,
validation_status, validation_reason, captured_at, raw_payload
)
VALUES
($1, $2, $3, $4, $5, 'CLOCK_IN', 'NFC', 'seed', $6, 'seed-device', $7, $8, 5, 0, TRUE, 'ACCEPTED', NULL, $9, '{"seeded":true}'::jsonb),
($1, $2, $3, $4, $5, 'CLOCK_OUT', 'NFC', 'seed', $6, 'seed-device', $7, $8, 5, 0, TRUE, 'ACCEPTED', NULL, $10, '{"seeded":true}'::jsonb)
`,
[
fixture.tenant.id,
fixture.assignments.completedAna.id,
fixture.shifts.completed.id,
fixture.staff.ana.id,
fixture.clockPoint.id,
fixture.clockPoint.nfcTagUid,
fixture.clockPoint.latitude,
fixture.clockPoint.longitude,
checkedInAt,
checkedOutAt,
]
);
const attendanceEvents = await client.query(
`
SELECT id, event_type
FROM attendance_events
WHERE assignment_id = $1
ORDER BY captured_at ASC
`,
[fixture.assignments.completedAna.id]
);
await client.query(
`
INSERT INTO attendance_sessions (
id, tenant_id, assignment_id, staff_id, clock_in_event_id, clock_out_event_id, status,
check_in_at, check_out_at, worked_minutes, metadata
)
VALUES ($1, $2, $3, $4, $5, $6, 'CLOSED', $7, $8, 435, '{"seeded":true}'::jsonb)
`,
[
'95f6017c-256c-4eb5-8033-eb942f018001',
fixture.tenant.id,
fixture.assignments.completedAna.id,
fixture.staff.ana.id,
attendanceEvents.rows.find((row) => row.event_type === 'CLOCK_IN')?.id,
attendanceEvents.rows.find((row) => row.event_type === 'CLOCK_OUT')?.id,
checkedInAt,
checkedOutAt,
]
);
await client.query(
`
INSERT INTO timesheets (
id, tenant_id, assignment_id, staff_id, status, regular_minutes, overtime_minutes, break_minutes, gross_pay_cents, metadata
)
VALUES ($1, $2, $3, $4, 'APPROVED', 420, 15, 30, 15950, '{"seeded":true}'::jsonb)
`,
[fixture.timesheets.completedAna.id, fixture.tenant.id, fixture.assignments.completedAna.id, fixture.staff.ana.id]
);
await client.query(
`
INSERT INTO documents (id, tenant_id, document_type, name, required_for_role_code, metadata)
VALUES ($1, $2, 'CERTIFICATION', $3, $4, '{"seeded":true}'::jsonb)
`,
[fixture.documents.foodSafety.id, fixture.tenant.id, fixture.documents.foodSafety.name, fixture.roles.barista.code]
);
await client.query(
`
INSERT INTO staff_documents (id, tenant_id, staff_id, document_id, file_uri, status, expires_at, metadata)
VALUES ($1, $2, $3, $4, $5, 'VERIFIED', $6, '{"seeded":true}'::jsonb)
`,
[
fixture.staffDocuments.foodSafety.id,
fixture.tenant.id,
fixture.staff.ana.id,
fixture.documents.foodSafety.id,
`gs://krow-workforce-dev-v2-private/uploads/${fixture.staff.ana.id}/food-handler-card.pdf`,
hoursFromNow(24 * 180),
]
);
await client.query(
`
INSERT INTO certificates (id, tenant_id, staff_id, certificate_type, certificate_number, issued_at, expires_at, status, metadata)
VALUES ($1, $2, $3, 'FOOD_SAFETY', 'FH-ANA-2026', $4, $5, 'VERIFIED', '{"seeded":true}'::jsonb)
`,
[
fixture.certificates.foodSafety.id,
fixture.tenant.id,
fixture.staff.ana.id,
hoursFromNow(-24 * 30),
hoursFromNow(24 * 180),
]
);
await client.query(
`
INSERT INTO verification_jobs (
tenant_id, staff_id, document_id, type, file_uri, status, idempotency_key,
provider_name, provider_reference, confidence, reasons, extracted, review, metadata
)
VALUES (
$1, $2, $3, 'certification', $4, 'APPROVED', 'seed-certification-job',
'seed', 'seed-certification-provider', 0.980, '["Verified by seed"]'::jsonb,
'{"certificateType":"FOOD_SAFETY"}'::jsonb, '{"decision":"APPROVED"}'::jsonb, '{"seeded":true}'::jsonb
)
`,
[
fixture.tenant.id,
fixture.staff.ana.id,
fixture.documents.foodSafety.id,
`gs://krow-workforce-dev-v2-private/uploads/${fixture.staff.ana.id}/food-handler-card.pdf`,
]
);
await client.query(
`
INSERT INTO accounts (
id, tenant_id, owner_type, owner_business_id, owner_vendor_id, owner_staff_id,
provider_name, provider_reference, last4, is_primary, metadata
)
VALUES
($1, $3, 'BUSINESS', $4, NULL, NULL, 'stripe', 'ba_business_demo', '6789', TRUE, '{"seeded":true}'::jsonb),
($2, $3, 'STAFF', NULL, NULL, $5, 'stripe', 'ba_staff_demo', '4321', TRUE, '{"seeded":true}'::jsonb)
`,
[
fixture.accounts.businessPrimary.id,
fixture.accounts.staffPrimary.id,
fixture.tenant.id,
fixture.business.id,
fixture.staff.ana.id,
]
);
await client.query(
`
INSERT INTO invoices (
id, tenant_id, order_id, business_id, vendor_id, invoice_number, status, currency_code,
subtotal_cents, tax_cents, total_cents, due_at, metadata
)
VALUES ($1, $2, $3, $4, $5, $6, 'PENDING_REVIEW', 'USD', 15250, 700, 15950, $7, '{"seeded":true}'::jsonb)
`,
[
fixture.invoices.completed.id,
fixture.tenant.id,
fixture.orders.completed.id,
fixture.business.id,
fixture.vendor.id,
fixture.invoices.completed.number,
invoiceDueAt,
]
);
await client.query(
`
INSERT INTO recent_payments (
id, tenant_id, invoice_id, assignment_id, staff_id, status, amount_cents, process_date, metadata
)
VALUES ($1, $2, $3, $4, $5, 'PENDING', 15950, NULL, '{"seeded":true}'::jsonb)
`,
[
fixture.recentPayments.completed.id,
fixture.tenant.id,
fixture.invoices.completed.id,
fixture.assignments.completedAna.id,
fixture.staff.ana.id,
]
);
await client.query(
`
INSERT INTO staff_favorites (id, tenant_id, business_id, staff_id, created_by_user_id, created_at)
VALUES ($1, $2, $3, $4, $5, NOW())
`,
[
fixture.favorites.ana.id,
fixture.tenant.id,
fixture.business.id,
fixture.staff.ana.id,
fixture.users.businessOwner.id,
]
);
await client.query(
`
INSERT INTO staff_reviews (
id, tenant_id, business_id, staff_id, assignment_id, reviewer_user_id, rating, review_text, tags, created_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, 5, 'Reliable, on time, and client friendly.', '["reliable","favorite"]'::jsonb, NOW(), NOW())
`,
[
fixture.reviews.anaCompleted.id,
fixture.tenant.id,
fixture.business.id,
fixture.staff.ana.id,
fixture.assignments.completedAna.id,
fixture.users.businessOwner.id,
]
);
await client.query(
`
INSERT INTO domain_events (tenant_id, aggregate_type, aggregate_id, sequence, event_type, actor_user_id, payload)
VALUES
($1, 'order', $2, 1, 'ORDER_CREATED', $3, '{"seeded":true}'::jsonb),
($1, 'assignment', $4, 1, 'STAFF_ASSIGNED', $3, '{"seeded":true}'::jsonb)
`,
[
fixture.tenant.id,
fixture.orders.completed.id,
fixture.users.businessOwner.id,
fixture.assignments.completedAna.id,
]
);
await client.query('COMMIT');
// eslint-disable-next-line no-console
console.log(JSON.stringify({
tenantId: fixture.tenant.id,
businessId: fixture.business.id,
vendorId: fixture.vendor.id,
staffId: fixture.staff.ana.id,
workforceId: fixture.workforce.ana.id,
openOrderId: fixture.orders.open.id,
openShiftId: fixture.shifts.open.id,
openShiftRoleId: fixture.shiftRoles.openBarista.id,
openApplicationId: fixture.applications.openAna.id,
completedOrderId: fixture.orders.completed.id,
completedAssignmentId: fixture.assignments.completedAna.id,
clockPointId: fixture.clockPoint.id,
nfcTagUid: fixture.clockPoint.nfcTagUid,
businessOwnerUid: fixture.users.businessOwner.id,
}, null, 2));
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
await pool.end();
}
}
main().catch((error) => {
// eslint-disable-next-line no-console
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,162 @@
export const V2DemoFixture = {
tenant: {
id: '6d5fa42c-1f38-49be-8895-8aeb0e731001',
slug: 'legendary-event-staffing',
name: 'Legendary Event Staffing and Entertainment',
},
users: {
businessOwner: {
id: process.env.V2_DEMO_OWNER_UID || 'dvpWnaBjT6UksS5lo04hfMTyq1q1',
email: process.env.V2_DEMO_OWNER_EMAIL || 'legendary@krowd.com',
displayName: 'Legendary Demo Owner',
},
operationsManager: {
id: 'demo-ops-manager',
email: 'ops+v2@krowd.com',
displayName: 'Wil Ops Lead',
},
vendorManager: {
id: 'demo-vendor-manager',
email: 'vendor+v2@krowd.com',
displayName: 'Vendor Manager',
},
},
business: {
id: '14f4fcfb-f21f-4ba9-9328-90f794a56001',
slug: 'google-mv-cafes',
name: 'Google Mountain View Cafes',
},
vendor: {
id: '80f8c8d3-9da8-4892-908f-4d4982af7001',
slug: 'legendary-pool-a',
name: 'Legendary Staffing Pool A',
},
roles: {
barista: {
id: '67c5010e-85f0-4f6b-99b7-167c9afdf001',
code: 'BARISTA',
name: 'Barista',
},
captain: {
id: '67c5010e-85f0-4f6b-99b7-167c9afdf002',
code: 'CAPTAIN',
name: 'Captain',
},
},
staff: {
ana: {
id: '4b7dff1a-1856-4d59-b450-5a6736461001',
fullName: 'Ana Barista',
email: 'ana.barista+v2@krowd.com',
phone: '+15557654321',
primaryRole: 'BARISTA',
},
},
workforce: {
ana: {
id: '4cc1d34a-87c3-4426-8ee0-a24c8bcfa001',
workforceNumber: 'WF-V2-ANA-001',
},
},
clockPoint: {
id: 'efb80ccf-3361-49c8-bc74-ff8cd4d2e001',
label: 'Google MV Cafe Clock Point',
address: '1600 Amphitheatre Pkwy, Mountain View, CA',
latitude: 37.4221,
longitude: -122.0841,
geofenceRadiusMeters: 120,
nfcTagUid: 'NFC-DEMO-ANA-001',
},
orders: {
open: {
id: 'b6132d7a-45c3-4879-b349-46b2fd518001',
number: 'ORD-V2-OPEN-1001',
title: 'Morning cafe staffing',
},
completed: {
id: 'b6132d7a-45c3-4879-b349-46b2fd518002',
number: 'ORD-V2-COMP-1002',
title: 'Completed catering shift',
},
},
shifts: {
open: {
id: '6e7dadad-99e4-45bb-b0da-7bb617954001',
code: 'SHIFT-V2-OPEN-1',
title: 'Open breakfast shift',
},
completed: {
id: '6e7dadad-99e4-45bb-b0da-7bb617954002',
code: 'SHIFT-V2-COMP-1',
title: 'Completed catering shift',
},
},
shiftRoles: {
openBarista: {
id: '4dd35b2b-4aaf-4c28-a91f-7bda05e2b001',
},
completedBarista: {
id: '4dd35b2b-4aaf-4c28-a91f-7bda05e2b002',
},
},
applications: {
openAna: {
id: 'd70d6441-6d0c-4fdb-9a29-c9d9e0c34001',
},
},
assignments: {
completedAna: {
id: 'f1d3f738-a132-4863-b222-4f9cb25aa001',
},
},
timesheets: {
completedAna: {
id: '41ea4057-0c55-4907-b525-07315b2b6001',
},
},
invoices: {
completed: {
id: '1455e15b-77f9-4c66-b2a8-dce35f7ac001',
number: 'INV-V2-2001',
},
},
recentPayments: {
completed: {
id: 'be6f736b-e945-4676-a73d-2912c7575001',
},
},
favorites: {
ana: {
id: 'ba5cb8fa-0be9-4ef4-a9fb-e60a8a48e001',
},
},
reviews: {
anaCompleted: {
id: '9b6bc737-fd69-4855-b425-6f0c2c4fd001',
},
},
documents: {
foodSafety: {
id: 'e6fd0183-34d9-4c23-9a9a-bf98da995001',
name: 'Food Handler Card',
},
},
staffDocuments: {
foodSafety: {
id: '4b157236-a4b0-4c44-b199-7d4ea1f95001',
},
},
certificates: {
foodSafety: {
id: 'df6452dc-4ec7-4d54-876d-26bf8ce5b001',
},
},
accounts: {
businessPrimary: {
id: '5d98e0ba-8e89-4ffb-aafd-df6bbe2fe001',
},
staffPrimary: {
id: '5d98e0ba-8e89-4ffb-aafd-df6bbe2fe002',
},
},
};

View File

@@ -0,0 +1,639 @@
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'ACTIVE' CHECK (status IN ('ACTIVE', 'INACTIVE')),
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT,
display_name TEXT,
phone TEXT,
status TEXT NOT NULL DEFAULT 'ACTIVE' CHECK (status IN ('ACTIVE', 'INVITED', 'DISABLED')),
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_unique
ON users (LOWER(email))
WHERE email IS NOT NULL;
CREATE TABLE IF NOT EXISTS tenant_memberships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
invited_email TEXT,
membership_status TEXT NOT NULL DEFAULT 'ACTIVE'
CHECK (membership_status IN ('INVITED', 'ACTIVE', 'SUSPENDED', 'REMOVED')),
base_role TEXT NOT NULL DEFAULT 'member'
CHECK (base_role IN ('admin', 'manager', 'member', 'viewer')),
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_tenant_membership_identity
CHECK (user_id IS NOT NULL OR invited_email IS NOT NULL)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_tenant_memberships_tenant_user
ON tenant_memberships (tenant_id, user_id)
WHERE user_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_tenant_memberships_tenant_invited_email
ON tenant_memberships (tenant_id, LOWER(invited_email))
WHERE invited_email IS NOT NULL;
CREATE TABLE IF NOT EXISTS businesses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
slug TEXT NOT NULL,
business_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'ACTIVE'
CHECK (status IN ('ACTIVE', 'INACTIVE', 'ARCHIVED')),
contact_name TEXT,
contact_email TEXT,
contact_phone TEXT,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_businesses_tenant_slug
ON businesses (tenant_id, slug);
CREATE TABLE IF NOT EXISTS business_memberships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
business_id UUID NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
invited_email TEXT,
membership_status TEXT NOT NULL DEFAULT 'ACTIVE'
CHECK (membership_status IN ('INVITED', 'ACTIVE', 'SUSPENDED', 'REMOVED')),
business_role TEXT NOT NULL DEFAULT 'member'
CHECK (business_role IN ('owner', 'manager', 'member', 'viewer')),
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_business_membership_identity
CHECK (user_id IS NOT NULL OR invited_email IS NOT NULL)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_business_memberships_business_user
ON business_memberships (business_id, user_id)
WHERE user_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_business_memberships_business_invited_email
ON business_memberships (business_id, LOWER(invited_email))
WHERE invited_email IS NOT NULL;
CREATE TABLE IF NOT EXISTS vendors (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
slug TEXT NOT NULL,
company_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'ACTIVE'
CHECK (status IN ('ACTIVE', 'INACTIVE', 'ARCHIVED')),
contact_name TEXT,
contact_email TEXT,
contact_phone TEXT,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_vendors_tenant_slug
ON vendors (tenant_id, slug);
CREATE TABLE IF NOT EXISTS vendor_memberships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
vendor_id UUID NOT NULL REFERENCES vendors(id) ON DELETE CASCADE,
user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
invited_email TEXT,
membership_status TEXT NOT NULL DEFAULT 'ACTIVE'
CHECK (membership_status IN ('INVITED', 'ACTIVE', 'SUSPENDED', 'REMOVED')),
vendor_role TEXT NOT NULL DEFAULT 'member'
CHECK (vendor_role IN ('owner', 'manager', 'member', 'viewer')),
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_vendor_membership_identity
CHECK (user_id IS NOT NULL OR invited_email IS NOT NULL)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_vendor_memberships_vendor_user
ON vendor_memberships (vendor_id, user_id)
WHERE user_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_vendor_memberships_vendor_invited_email
ON vendor_memberships (vendor_id, LOWER(invited_email))
WHERE invited_email IS NOT NULL;
CREATE TABLE IF NOT EXISTS staffs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
full_name TEXT NOT NULL,
email TEXT,
phone TEXT,
status TEXT NOT NULL DEFAULT 'ACTIVE'
CHECK (status IN ('ACTIVE', 'INVITED', 'INACTIVE', 'BLOCKED')),
primary_role TEXT,
onboarding_status TEXT NOT NULL DEFAULT 'PENDING'
CHECK (onboarding_status IN ('PENDING', 'IN_PROGRESS', 'COMPLETED')),
average_rating NUMERIC(3, 2) NOT NULL DEFAULT 0 CHECK (average_rating >= 0 AND average_rating <= 5),
rating_count INTEGER NOT NULL DEFAULT 0 CHECK (rating_count >= 0),
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_staffs_tenant_user
ON staffs (tenant_id, user_id)
WHERE user_id IS NOT NULL;
CREATE TABLE IF NOT EXISTS workforce (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
vendor_id UUID NOT NULL REFERENCES vendors(id) ON DELETE CASCADE,
staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE CASCADE,
workforce_number TEXT NOT NULL,
employment_type TEXT NOT NULL
CHECK (employment_type IN ('W2', 'W1099', 'TEMP', 'CONTRACT')),
status TEXT NOT NULL DEFAULT 'ACTIVE'
CHECK (status IN ('ACTIVE', 'INACTIVE', 'SUSPENDED')),
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_workforce_vendor_staff
ON workforce (vendor_id, staff_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_workforce_number_tenant
ON workforce (tenant_id, workforce_number);
CREATE TABLE IF NOT EXISTS roles_catalog (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
code TEXT NOT NULL,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'ACTIVE'
CHECK (status IN ('ACTIVE', 'INACTIVE')),
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_roles_catalog_tenant_code
ON roles_catalog (tenant_id, code);
CREATE TABLE IF NOT EXISTS staff_roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE CASCADE,
role_id UUID NOT NULL REFERENCES roles_catalog(id) ON DELETE CASCADE,
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_staff_roles_staff_role
ON staff_roles (staff_id, role_id);
CREATE TABLE IF NOT EXISTS clock_points (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
business_id UUID REFERENCES businesses(id) ON DELETE SET NULL,
label TEXT NOT NULL,
address TEXT,
latitude NUMERIC(9, 6),
longitude NUMERIC(9, 6),
geofence_radius_meters INTEGER NOT NULL DEFAULT 100 CHECK (geofence_radius_meters > 0),
nfc_tag_uid TEXT,
status TEXT NOT NULL DEFAULT 'ACTIVE'
CHECK (status IN ('ACTIVE', 'INACTIVE')),
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_clock_points_tenant_nfc_tag
ON clock_points (tenant_id, nfc_tag_uid)
WHERE nfc_tag_uid IS NOT NULL;
CREATE TABLE IF NOT EXISTS orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
business_id UUID NOT NULL REFERENCES businesses(id) ON DELETE RESTRICT,
vendor_id UUID REFERENCES vendors(id) ON DELETE SET NULL,
order_number TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'DRAFT'
CHECK (status IN ('DRAFT', 'OPEN', 'FILLED', 'ACTIVE', 'COMPLETED', 'CANCELLED')),
service_type TEXT NOT NULL DEFAULT 'EVENT'
CHECK (service_type IN ('EVENT', 'CATERING', 'HOTEL', 'RESTAURANT', 'OTHER')),
starts_at TIMESTAMPTZ,
ends_at TIMESTAMPTZ,
location_name TEXT,
location_address TEXT,
latitude NUMERIC(9, 6),
longitude NUMERIC(9, 6),
notes TEXT,
created_by_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_orders_time_window CHECK (starts_at IS NULL OR ends_at IS NULL OR starts_at < ends_at)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_orders_tenant_order_number
ON orders (tenant_id, order_number);
CREATE INDEX IF NOT EXISTS idx_orders_tenant_business_status
ON orders (tenant_id, business_id, status, created_at DESC);
CREATE TABLE IF NOT EXISTS shifts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
business_id UUID NOT NULL REFERENCES businesses(id) ON DELETE RESTRICT,
vendor_id UUID REFERENCES vendors(id) ON DELETE SET NULL,
clock_point_id UUID REFERENCES clock_points(id) ON DELETE SET NULL,
shift_code TEXT NOT NULL,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'OPEN'
CHECK (status IN ('DRAFT', 'OPEN', 'PENDING_CONFIRMATION', 'ASSIGNED', 'ACTIVE', 'COMPLETED', 'CANCELLED')),
starts_at TIMESTAMPTZ NOT NULL,
ends_at TIMESTAMPTZ NOT NULL,
timezone TEXT NOT NULL DEFAULT 'UTC',
location_name TEXT,
location_address TEXT,
latitude NUMERIC(9, 6),
longitude NUMERIC(9, 6),
geofence_radius_meters INTEGER CHECK (geofence_radius_meters IS NULL OR geofence_radius_meters > 0),
required_workers INTEGER NOT NULL DEFAULT 1 CHECK (required_workers > 0),
assigned_workers INTEGER NOT NULL DEFAULT 0 CHECK (assigned_workers >= 0),
notes TEXT,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_shifts_time_window CHECK (starts_at < ends_at),
CONSTRAINT chk_shifts_assigned_workers CHECK (assigned_workers <= required_workers)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_shifts_order_shift_code
ON shifts (order_id, shift_code);
CREATE INDEX IF NOT EXISTS idx_shifts_tenant_time
ON shifts (tenant_id, starts_at, ends_at);
CREATE TABLE IF NOT EXISTS shift_roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
shift_id UUID NOT NULL REFERENCES shifts(id) ON DELETE CASCADE,
role_id UUID REFERENCES roles_catalog(id) ON DELETE SET NULL,
role_code TEXT NOT NULL,
role_name TEXT NOT NULL,
workers_needed INTEGER NOT NULL CHECK (workers_needed > 0),
assigned_count INTEGER NOT NULL DEFAULT 0 CHECK (assigned_count >= 0),
pay_rate_cents INTEGER NOT NULL DEFAULT 0 CHECK (pay_rate_cents >= 0),
bill_rate_cents INTEGER NOT NULL DEFAULT 0 CHECK (bill_rate_cents >= 0),
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_shift_roles_assigned_count CHECK (assigned_count <= workers_needed)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_shift_roles_shift_role_code
ON shift_roles (shift_id, role_code);
CREATE TABLE IF NOT EXISTS applications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
shift_id UUID NOT NULL REFERENCES shifts(id) ON DELETE CASCADE,
shift_role_id UUID NOT NULL REFERENCES shift_roles(id) ON DELETE CASCADE,
staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'PENDING'
CHECK (status IN ('PENDING', 'CONFIRMED', 'CHECKED_IN', 'LATE', 'NO_SHOW', 'COMPLETED', 'REJECTED', 'CANCELLED')),
origin TEXT NOT NULL DEFAULT 'STAFF'
CHECK (origin IN ('STAFF', 'BUSINESS', 'VENDOR', 'SYSTEM')),
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_applications_shift_role_staff
ON applications (shift_role_id, staff_id);
CREATE INDEX IF NOT EXISTS idx_applications_staff_status
ON applications (staff_id, status, applied_at DESC);
CREATE TABLE IF NOT EXISTS assignments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
business_id UUID NOT NULL REFERENCES businesses(id) ON DELETE RESTRICT,
vendor_id UUID REFERENCES vendors(id) ON DELETE SET NULL,
shift_id UUID NOT NULL REFERENCES shifts(id) ON DELETE CASCADE,
shift_role_id UUID NOT NULL REFERENCES shift_roles(id) ON DELETE CASCADE,
workforce_id UUID NOT NULL REFERENCES workforce(id) ON DELETE RESTRICT,
staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE RESTRICT,
application_id UUID REFERENCES applications(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'ASSIGNED'
CHECK (status IN ('ASSIGNED', 'ACCEPTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED', 'CANCELLED', 'NO_SHOW')),
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
accepted_at TIMESTAMPTZ,
checked_in_at TIMESTAMPTZ,
checked_out_at TIMESTAMPTZ,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_assignments_shift_role_workforce
ON assignments (shift_role_id, workforce_id);
CREATE INDEX IF NOT EXISTS idx_assignments_staff_status
ON assignments (staff_id, status, assigned_at DESC);
CREATE TABLE IF NOT EXISTS attendance_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
assignment_id UUID NOT NULL REFERENCES assignments(id) ON DELETE CASCADE,
shift_id UUID NOT NULL REFERENCES shifts(id) ON DELETE CASCADE,
staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE RESTRICT,
clock_point_id UUID REFERENCES clock_points(id) ON DELETE SET NULL,
event_type TEXT NOT NULL
CHECK (event_type IN ('CLOCK_IN', 'CLOCK_OUT', 'MANUAL_ADJUSTMENT')),
source_type TEXT NOT NULL
CHECK (source_type IN ('NFC', 'GEO', 'QR', 'MANUAL', 'SYSTEM')),
source_reference TEXT,
nfc_tag_uid TEXT,
device_id TEXT,
latitude NUMERIC(9, 6),
longitude NUMERIC(9, 6),
accuracy_meters INTEGER CHECK (accuracy_meters IS NULL OR accuracy_meters >= 0),
distance_to_clock_point_meters INTEGER CHECK (distance_to_clock_point_meters IS NULL OR distance_to_clock_point_meters >= 0),
within_geofence BOOLEAN,
validation_status TEXT NOT NULL DEFAULT 'ACCEPTED'
CHECK (validation_status IN ('ACCEPTED', 'FLAGGED', 'REJECTED')),
validation_reason TEXT,
captured_at TIMESTAMPTZ NOT NULL,
raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_attendance_events_assignment_time
ON attendance_events (assignment_id, captured_at DESC);
CREATE INDEX IF NOT EXISTS idx_attendance_events_staff_time
ON attendance_events (staff_id, captured_at DESC);
CREATE TABLE IF NOT EXISTS attendance_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
assignment_id UUID NOT NULL UNIQUE REFERENCES assignments(id) ON DELETE CASCADE,
staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE RESTRICT,
clock_in_event_id UUID REFERENCES attendance_events(id) ON DELETE SET NULL,
clock_out_event_id UUID REFERENCES attendance_events(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'OPEN'
CHECK (status IN ('OPEN', 'CLOSED', 'DISPUTED')),
check_in_at TIMESTAMPTZ,
check_out_at TIMESTAMPTZ,
worked_minutes INTEGER NOT NULL DEFAULT 0 CHECK (worked_minutes >= 0),
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS timesheets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
assignment_id UUID NOT NULL UNIQUE REFERENCES assignments(id) ON DELETE CASCADE,
staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE RESTRICT,
status TEXT NOT NULL DEFAULT 'PENDING'
CHECK (status IN ('PENDING', 'SUBMITTED', 'APPROVED', 'REJECTED', 'PAID')),
regular_minutes INTEGER NOT NULL DEFAULT 0 CHECK (regular_minutes >= 0),
overtime_minutes INTEGER NOT NULL DEFAULT 0 CHECK (overtime_minutes >= 0),
break_minutes INTEGER NOT NULL DEFAULT 0 CHECK (break_minutes >= 0),
gross_pay_cents BIGINT NOT NULL DEFAULT 0 CHECK (gross_pay_cents >= 0),
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
document_type TEXT NOT NULL,
name TEXT NOT NULL,
required_for_role_code TEXT,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_documents_tenant_type_name
ON documents (tenant_id, document_type, name);
CREATE TABLE IF NOT EXISTS staff_documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE CASCADE,
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
file_uri TEXT,
status TEXT NOT NULL DEFAULT 'PENDING'
CHECK (status IN ('PENDING', 'VERIFIED', 'REJECTED', 'EXPIRED')),
expires_at TIMESTAMPTZ,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_staff_documents_staff_document
ON staff_documents (staff_id, document_id);
CREATE TABLE IF NOT EXISTS certificates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE CASCADE,
certificate_type TEXT NOT NULL,
certificate_number TEXT,
issued_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'PENDING'
CHECK (status IN ('PENDING', 'VERIFIED', 'REJECTED', 'EXPIRED')),
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS verification_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
staff_id UUID REFERENCES staffs(id) ON DELETE SET NULL,
document_id UUID REFERENCES documents(id) ON DELETE SET NULL,
type TEXT NOT NULL,
file_uri TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'PENDING'
CHECK (status IN ('PENDING', 'PROCESSING', 'AUTO_PASS', 'AUTO_FAIL', 'NEEDS_REVIEW', 'APPROVED', 'REJECTED', 'ERROR')),
idempotency_key TEXT,
provider_name TEXT,
provider_reference TEXT,
confidence NUMERIC(4, 3),
reasons JSONB NOT NULL DEFAULT '[]'::jsonb,
extracted JSONB NOT NULL DEFAULT '{}'::jsonb,
review JSONB NOT NULL DEFAULT '{}'::jsonb,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_verification_jobs_tenant_idempotency
ON verification_jobs (tenant_id, idempotency_key)
WHERE idempotency_key IS NOT NULL;
CREATE TABLE IF NOT EXISTS verification_reviews (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
verification_job_id UUID NOT NULL REFERENCES verification_jobs(id) ON DELETE CASCADE,
reviewer_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
decision TEXT NOT NULL CHECK (decision IN ('APPROVED', 'REJECTED')),
note TEXT,
reason_code TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS verification_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
verification_job_id UUID NOT NULL REFERENCES verification_jobs(id) ON DELETE CASCADE,
from_status TEXT,
to_status TEXT NOT NULL,
actor_type TEXT NOT NULL,
actor_id TEXT,
details JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
owner_type TEXT NOT NULL CHECK (owner_type IN ('BUSINESS', 'VENDOR', 'STAFF')),
owner_business_id UUID REFERENCES businesses(id) ON DELETE CASCADE,
owner_vendor_id UUID REFERENCES vendors(id) ON DELETE CASCADE,
owner_staff_id UUID REFERENCES staffs(id) ON DELETE CASCADE,
provider_name TEXT NOT NULL,
provider_reference TEXT NOT NULL,
last4 TEXT,
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_accounts_single_owner
CHECK (
(owner_business_id IS NOT NULL)::INTEGER
+ (owner_vendor_id IS NOT NULL)::INTEGER
+ (owner_staff_id IS NOT NULL)::INTEGER = 1
)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_owner_primary_business
ON accounts (owner_business_id)
WHERE owner_business_id IS NOT NULL AND is_primary = TRUE;
CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_owner_primary_vendor
ON accounts (owner_vendor_id)
WHERE owner_vendor_id IS NOT NULL AND is_primary = TRUE;
CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_owner_primary_staff
ON accounts (owner_staff_id)
WHERE owner_staff_id IS NOT NULL AND is_primary = TRUE;
CREATE TABLE IF NOT EXISTS invoices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
business_id UUID NOT NULL REFERENCES businesses(id) ON DELETE RESTRICT,
vendor_id UUID REFERENCES vendors(id) ON DELETE SET NULL,
invoice_number TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'PENDING'
CHECK (status IN ('DRAFT', 'PENDING', 'PENDING_REVIEW', 'APPROVED', 'PAID', 'OVERDUE', 'DISPUTED', 'VOID')),
currency_code TEXT NOT NULL DEFAULT 'USD',
subtotal_cents BIGINT NOT NULL DEFAULT 0 CHECK (subtotal_cents >= 0),
tax_cents BIGINT NOT NULL DEFAULT 0 CHECK (tax_cents >= 0),
total_cents BIGINT NOT NULL DEFAULT 0 CHECK (total_cents >= 0),
due_at TIMESTAMPTZ,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_invoices_tenant_invoice_number
ON invoices (tenant_id, invoice_number);
CREATE TABLE IF NOT EXISTS recent_payments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
assignment_id UUID REFERENCES assignments(id) ON DELETE SET NULL,
staff_id UUID REFERENCES staffs(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'PENDING'
CHECK (status IN ('PENDING', 'PROCESSING', 'PAID', 'FAILED')),
amount_cents BIGINT NOT NULL CHECK (amount_cents >= 0),
process_date TIMESTAMPTZ,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS staff_reviews (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
business_id UUID NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE CASCADE,
assignment_id UUID NOT NULL REFERENCES assignments(id) ON DELETE CASCADE,
reviewer_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
rating SMALLINT NOT NULL CHECK (rating BETWEEN 1 AND 5),
review_text TEXT,
tags JSONB NOT NULL DEFAULT '[]'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_staff_reviews_business_assignment_staff
ON staff_reviews (business_id, assignment_id, staff_id);
CREATE INDEX IF NOT EXISTS idx_staff_reviews_staff_created_at
ON staff_reviews (staff_id, created_at DESC);
CREATE TABLE IF NOT EXISTS staff_favorites (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
business_id UUID NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE CASCADE,
created_by_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_staff_favorites_business_staff
ON staff_favorites (business_id, staff_id);
CREATE TABLE IF NOT EXISTS domain_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
aggregate_type TEXT NOT NULL,
aggregate_id UUID NOT NULL,
sequence INTEGER NOT NULL CHECK (sequence > 0),
event_type TEXT NOT NULL,
actor_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_domain_events_aggregate_sequence
ON domain_events (tenant_id, aggregate_type, aggregate_id, sequence);

View File

@@ -8,7 +8,7 @@ import { createCommandsRouter } from './routes/commands.js';
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
export function createApp() {
export function createApp(options = {}) {
const app = express();
app.use(requestContext);
@@ -21,7 +21,7 @@ export function createApp() {
app.use(express.json({ limit: '2mb' }));
app.use(healthRouter);
app.use('/commands', createCommandsRouter());
app.use('/commands', createCommandsRouter(options.commandHandlers));
app.use(notFoundHandler);
app.use(errorHandler);

View File

@@ -0,0 +1,14 @@
import { z } from 'zod';
export const attendanceCommandSchema = z.object({
assignmentId: z.string().uuid(),
sourceType: z.enum(['NFC', 'GEO', 'QR', 'MANUAL', 'SYSTEM']),
sourceReference: z.string().max(255).optional(),
nfcTagUid: z.string().max(255).optional(),
deviceId: z.string().max(255).optional(),
latitude: z.number().min(-90).max(90).optional(),
longitude: z.number().min(-180).max(180).optional(),
accuracyMeters: z.number().int().nonnegative().optional(),
capturedAt: z.string().datetime().optional(),
rawPayload: z.record(z.any()).optional(),
});

View File

@@ -0,0 +1,7 @@
import { z } from 'zod';
export const favoriteStaffSchema = z.object({
tenantId: z.string().uuid(),
businessId: z.string().uuid(),
staffId: z.string().uuid(),
});

View File

@@ -0,0 +1,8 @@
import { z } from 'zod';
export const orderCancelSchema = z.object({
orderId: z.string().uuid(),
tenantId: z.string().uuid(),
reason: z.string().max(1000).optional(),
metadata: z.record(z.any()).optional(),
});

View File

@@ -0,0 +1,57 @@
import { z } from 'zod';
const roleSchema = z.object({
roleCode: z.string().min(1).max(100),
roleName: z.string().min(1).max(120),
workersNeeded: z.number().int().positive(),
payRateCents: z.number().int().nonnegative().optional(),
billRateCents: z.number().int().nonnegative().optional(),
metadata: z.record(z.any()).optional(),
});
const shiftSchema = z.object({
shiftCode: z.string().min(1).max(80),
title: z.string().min(1).max(160),
status: z.enum([
'DRAFT',
'OPEN',
'PENDING_CONFIRMATION',
'ASSIGNED',
'ACTIVE',
'COMPLETED',
'CANCELLED',
]).optional(),
startsAt: z.string().datetime(),
endsAt: z.string().datetime(),
timezone: z.string().min(1).max(80).optional(),
clockPointId: z.string().uuid().optional(),
locationName: z.string().max(160).optional(),
locationAddress: z.string().max(300).optional(),
latitude: z.number().min(-90).max(90).optional(),
longitude: z.number().min(-180).max(180).optional(),
geofenceRadiusMeters: z.number().int().positive().optional(),
requiredWorkers: z.number().int().positive(),
notes: z.string().max(5000).optional(),
metadata: z.record(z.any()).optional(),
roles: z.array(roleSchema).min(1),
});
export const orderCreateSchema = z.object({
tenantId: z.string().uuid(),
businessId: z.string().uuid(),
vendorId: z.string().uuid().optional(),
orderNumber: z.string().min(1).max(80),
title: z.string().min(1).max(160),
description: z.string().max(5000).optional(),
status: z.enum(['DRAFT', 'OPEN', 'FILLED', 'ACTIVE', 'COMPLETED', 'CANCELLED']).optional(),
serviceType: z.enum(['EVENT', 'CATERING', 'HOTEL', 'RESTAURANT', 'OTHER']).optional(),
startsAt: z.string().datetime().optional(),
endsAt: z.string().datetime().optional(),
locationName: z.string().max(160).optional(),
locationAddress: z.string().max(300).optional(),
latitude: z.number().min(-90).max(90).optional(),
longitude: z.number().min(-180).max(180).optional(),
notes: z.string().max(5000).optional(),
metadata: z.record(z.any()).optional(),
shifts: z.array(shiftSchema).min(1),
});

View File

@@ -0,0 +1,35 @@
import { z } from 'zod';
const nullableString = (max) => z.union([z.string().max(max), z.null()]);
const nullableDateTime = z.union([z.string().datetime(), z.null()]);
const nullableUuid = z.union([z.string().uuid(), z.null()]);
const orderUpdateShape = {
orderId: z.string().uuid(),
tenantId: z.string().uuid(),
vendorId: nullableUuid.optional(),
title: nullableString(160).optional(),
description: nullableString(5000).optional(),
status: z.enum(['DRAFT', 'OPEN', 'FILLED', 'ACTIVE', 'COMPLETED']).optional(),
serviceType: z.enum(['EVENT', 'CATERING', 'HOTEL', 'RESTAURANT', 'OTHER']).optional(),
startsAt: nullableDateTime.optional(),
endsAt: nullableDateTime.optional(),
locationName: nullableString(160).optional(),
locationAddress: nullableString(300).optional(),
latitude: z.union([z.number().min(-90).max(90), z.null()]).optional(),
longitude: z.union([z.number().min(-180).max(180), z.null()]).optional(),
notes: nullableString(5000).optional(),
metadata: z.record(z.any()).optional(),
};
export const orderUpdateSchema = z.object(orderUpdateShape).superRefine((value, ctx) => {
const mutableKeys = Object.keys(orderUpdateShape).filter((key) => !['orderId', 'tenantId'].includes(key));
const hasMutableField = mutableKeys.some((key) => Object.prototype.hasOwnProperty.call(value, key));
if (!hasMutableField) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'At least one mutable order field must be provided',
path: [],
});
}
});

View File

@@ -0,0 +1,8 @@
import { z } from 'zod';
export const shiftAcceptSchema = z.object({
shiftId: z.string().uuid().optional(),
shiftRoleId: z.string().uuid(),
workforceId: z.string().uuid(),
metadata: z.record(z.any()).optional(),
});

View File

@@ -0,0 +1,10 @@
import { z } from 'zod';
export const shiftAssignStaffSchema = z.object({
shiftId: z.string().uuid(),
tenantId: z.string().uuid(),
shiftRoleId: z.string().uuid(),
workforceId: z.string().uuid(),
applicationId: z.string().uuid().optional(),
metadata: z.record(z.any()).optional(),
});

View File

@@ -0,0 +1,17 @@
import { z } from 'zod';
export const shiftStatusChangeSchema = z.object({
shiftId: z.string().uuid(),
tenantId: z.string().uuid(),
status: z.enum([
'DRAFT',
'OPEN',
'PENDING_CONFIRMATION',
'ASSIGNED',
'ACTIVE',
'COMPLETED',
'CANCELLED',
]),
reason: z.string().max(1000).optional(),
metadata: z.record(z.any()).optional(),
});

View File

@@ -0,0 +1,11 @@
import { z } from 'zod';
export const staffReviewSchema = z.object({
tenantId: z.string().uuid(),
businessId: z.string().uuid(),
staffId: z.string().uuid(),
assignmentId: z.string().uuid(),
rating: z.number().int().min(1).max(5),
reviewText: z.string().max(5000).optional(),
tags: z.array(z.string().min(1).max(80)).max(20).optional(),
});

View File

@@ -3,10 +3,45 @@ import { AppError } from '../lib/errors.js';
import { requireAuth, requirePolicy } from '../middleware/auth.js';
import { requireIdempotencyKey } from '../middleware/idempotency.js';
import { buildIdempotencyKey, readIdempotentResult, writeIdempotentResult } from '../services/idempotency-store.js';
import { commandBaseSchema } from '../contracts/commands/command-base.js';
import {
addFavoriteStaff,
clockIn,
clockOut,
createOrder,
createStaffReview,
updateOrder,
cancelOrder,
changeShiftStatus,
assignStaffToShift,
removeFavoriteStaff,
acceptShift,
} from '../services/command-service.js';
import { attendanceCommandSchema } from '../contracts/commands/attendance.js';
import { favoriteStaffSchema } from '../contracts/commands/favorite-staff.js';
import { orderCancelSchema } from '../contracts/commands/order-cancel.js';
import { orderCreateSchema } from '../contracts/commands/order-create.js';
import { orderUpdateSchema } from '../contracts/commands/order-update.js';
import { shiftAssignStaffSchema } from '../contracts/commands/shift-assign-staff.js';
import { shiftAcceptSchema } from '../contracts/commands/shift-accept.js';
import { shiftStatusChangeSchema } from '../contracts/commands/shift-status-change.js';
import { staffReviewSchema } from '../contracts/commands/staff-review.js';
function parseBody(body) {
const parsed = commandBaseSchema.safeParse(body || {});
const defaultHandlers = {
addFavoriteStaff,
assignStaffToShift,
cancelOrder,
changeShiftStatus,
clockIn,
clockOut,
createOrder,
createStaffReview,
removeFavoriteStaff,
acceptShift,
updateOrder,
};
function parseBody(schema, body) {
const parsed = schema.safeParse(body || {});
if (!parsed.success) {
throw new AppError('VALIDATION_ERROR', 'Invalid command payload', 400, {
issues: parsed.error.issues,
@@ -15,50 +50,37 @@ function parseBody(body) {
return parsed.data;
}
function createCommandResponse(route, requestId, idempotencyKey) {
return {
accepted: true,
async function runIdempotentCommand(req, res, work) {
const route = `${req.baseUrl}${req.route.path}`;
const compositeKey = buildIdempotencyKey({
userId: req.actor.uid,
route,
commandId: `${route}:${Date.now()}`,
idempotencyKey,
requestId,
idempotencyKey: req.idempotencyKey,
});
const existing = await readIdempotentResult(compositeKey);
if (existing) {
return res.status(existing.statusCode).json(existing.payload);
}
const payload = await work();
const responsePayload = {
...payload,
idempotencyKey: req.idempotencyKey,
requestId: req.requestId,
};
const persisted = await writeIdempotentResult({
compositeKey,
userId: req.actor.uid,
route,
idempotencyKey: req.idempotencyKey,
payload: responsePayload,
statusCode: 200,
});
return res.status(persisted.statusCode).json(persisted.payload);
}
function buildCommandHandler(policyAction, policyResource) {
return async (req, res, next) => {
try {
parseBody(req.body);
const route = `${req.baseUrl}${req.route.path}`;
const compositeKey = buildIdempotencyKey({
userId: req.actor.uid,
route,
idempotencyKey: req.idempotencyKey,
});
const existing = await readIdempotentResult(compositeKey);
if (existing) {
return res.status(existing.statusCode).json(existing.payload);
}
const payload = createCommandResponse(route, req.requestId, req.idempotencyKey);
const persisted = await writeIdempotentResult({
compositeKey,
userId: req.actor.uid,
route,
idempotencyKey: req.idempotencyKey,
payload,
statusCode: 200,
});
return res.status(persisted.statusCode).json(persisted.payload);
} catch (error) {
return next(error);
}
};
}
export function createCommandsRouter() {
export function createCommandsRouter(handlers = defaultHandlers) {
const router = Router();
router.post(
@@ -66,7 +88,14 @@ export function createCommandsRouter() {
requireAuth,
requireIdempotencyKey,
requirePolicy('orders.create', 'order'),
buildCommandHandler('orders.create', 'order')
async (req, res, next) => {
try {
const payload = parseBody(orderCreateSchema, req.body);
return await runIdempotentCommand(req, res, () => handlers.createOrder(req.actor, payload));
} catch (error) {
return next(error);
}
}
);
router.post(
@@ -74,7 +103,17 @@ export function createCommandsRouter() {
requireAuth,
requireIdempotencyKey,
requirePolicy('orders.update', 'order'),
buildCommandHandler('orders.update', 'order')
async (req, res, next) => {
try {
const payload = parseBody(orderUpdateSchema, {
...req.body,
orderId: req.params.orderId,
});
return await runIdempotentCommand(req, res, () => handlers.updateOrder(req.actor, payload));
} catch (error) {
return next(error);
}
}
);
router.post(
@@ -82,7 +121,17 @@ export function createCommandsRouter() {
requireAuth,
requireIdempotencyKey,
requirePolicy('orders.cancel', 'order'),
buildCommandHandler('orders.cancel', 'order')
async (req, res, next) => {
try {
const payload = parseBody(orderCancelSchema, {
...req.body,
orderId: req.params.orderId,
});
return await runIdempotentCommand(req, res, () => handlers.cancelOrder(req.actor, payload));
} catch (error) {
return next(error);
}
}
);
router.post(
@@ -90,7 +139,17 @@ export function createCommandsRouter() {
requireAuth,
requireIdempotencyKey,
requirePolicy('shifts.change-status', 'shift'),
buildCommandHandler('shifts.change-status', 'shift')
async (req, res, next) => {
try {
const payload = parseBody(shiftStatusChangeSchema, {
...req.body,
shiftId: req.params.shiftId,
});
return await runIdempotentCommand(req, res, () => handlers.changeShiftStatus(req.actor, payload));
} catch (error) {
return next(error);
}
}
);
router.post(
@@ -98,7 +157,17 @@ export function createCommandsRouter() {
requireAuth,
requireIdempotencyKey,
requirePolicy('shifts.assign-staff', 'shift'),
buildCommandHandler('shifts.assign-staff', 'shift')
async (req, res, next) => {
try {
const payload = parseBody(shiftAssignStaffSchema, {
...req.body,
shiftId: req.params.shiftId,
});
return await runIdempotentCommand(req, res, () => handlers.assignStaffToShift(req.actor, payload));
} catch (error) {
return next(error);
}
}
);
router.post(
@@ -106,7 +175,102 @@ export function createCommandsRouter() {
requireAuth,
requireIdempotencyKey,
requirePolicy('shifts.accept', 'shift'),
buildCommandHandler('shifts.accept', 'shift')
async (req, res, next) => {
try {
const payload = parseBody(shiftAcceptSchema, {
...req.body,
shiftId: req.params.shiftId,
});
return await runIdempotentCommand(req, res, () => handlers.acceptShift(req.actor, payload));
} catch (error) {
return next(error);
}
}
);
router.post(
'/attendance/clock-in',
requireAuth,
requireIdempotencyKey,
requirePolicy('attendance.clock-in', 'attendance'),
async (req, res, next) => {
try {
const payload = parseBody(attendanceCommandSchema, req.body);
return await runIdempotentCommand(req, res, () => handlers.clockIn(req.actor, payload));
} catch (error) {
return next(error);
}
}
);
router.post(
'/attendance/clock-out',
requireAuth,
requireIdempotencyKey,
requirePolicy('attendance.clock-out', 'attendance'),
async (req, res, next) => {
try {
const payload = parseBody(attendanceCommandSchema, req.body);
return await runIdempotentCommand(req, res, () => handlers.clockOut(req.actor, payload));
} catch (error) {
return next(error);
}
}
);
router.post(
'/businesses/:businessId/favorite-staff',
requireAuth,
requireIdempotencyKey,
requirePolicy('business.favorite-staff', 'staff'),
async (req, res, next) => {
try {
const payload = parseBody(favoriteStaffSchema, {
...req.body,
businessId: req.params.businessId,
});
return await runIdempotentCommand(req, res, () => handlers.addFavoriteStaff(req.actor, payload));
} catch (error) {
return next(error);
}
}
);
router.delete(
'/businesses/:businessId/favorite-staff/:staffId',
requireAuth,
requireIdempotencyKey,
requirePolicy('business.unfavorite-staff', 'staff'),
async (req, res, next) => {
try {
const payload = parseBody(favoriteStaffSchema, {
...req.body,
businessId: req.params.businessId,
staffId: req.params.staffId,
});
return await runIdempotentCommand(req, res, () => handlers.removeFavoriteStaff(req.actor, payload));
} catch (error) {
return next(error);
}
}
);
router.post(
'/assignments/:assignmentId/reviews',
requireAuth,
requireIdempotencyKey,
requirePolicy('assignments.review-staff', 'assignment'),
async (req, res, next) => {
try {
const payload = parseBody(staffReviewSchema, {
...req.body,
assignmentId: req.params.assignmentId,
});
return await runIdempotentCommand(req, res, () => handlers.createStaffReview(req.actor, payload));
} catch (error) {
return next(error);
}
}
);
return router;

View File

@@ -1,4 +1,5 @@
import { Router } from 'express';
import { checkDatabaseHealth, isDatabaseConfigured } from '../services/db.js';
export const healthRouter = Router();
@@ -13,3 +14,32 @@ function healthHandler(req, res) {
healthRouter.get('/health', healthHandler);
healthRouter.get('/healthz', healthHandler);
healthRouter.get('/readyz', async (req, res) => {
if (!isDatabaseConfigured()) {
return res.status(503).json({
ok: false,
service: 'krow-command-api',
status: 'DATABASE_NOT_CONFIGURED',
requestId: req.requestId,
});
}
try {
const ok = await checkDatabaseHealth();
return res.status(ok ? 200 : 503).json({
ok,
service: 'krow-command-api',
status: ok ? 'READY' : 'DATABASE_UNAVAILABLE',
requestId: req.requestId,
});
} catch (error) {
return res.status(503).json({
ok: false,
service: 'krow-command-api',
status: 'DATABASE_UNAVAILABLE',
details: { message: error.message },
requestId: req.requestId,
});
}
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,94 @@
import { Pool } from 'pg';
let pool;
function parseIntOrDefault(value, fallback) {
const parsed = Number.parseInt(`${value || fallback}`, 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
export function resolveDatabasePoolConfig({
preferIdempotency = false,
maxEnvVar = 'DB_POOL_MAX',
} = {}) {
const primaryUrl = preferIdempotency
? process.env.IDEMPOTENCY_DATABASE_URL || process.env.DATABASE_URL
: process.env.DATABASE_URL || process.env.IDEMPOTENCY_DATABASE_URL;
if (primaryUrl) {
return {
connectionString: primaryUrl,
max: parseIntOrDefault(process.env[maxEnvVar], 10),
idleTimeoutMillis: parseIntOrDefault(process.env.DB_IDLE_TIMEOUT_MS, 30000),
};
}
const user = process.env.DB_USER;
const password = process.env.DB_PASSWORD;
const database = process.env.DB_NAME;
const host = process.env.DB_HOST || (
process.env.INSTANCE_CONNECTION_NAME
? `/cloudsql/${process.env.INSTANCE_CONNECTION_NAME}`
: ''
);
if (!user || password == null || !database || !host) {
return null;
}
return {
host,
port: parseIntOrDefault(process.env.DB_PORT, 5432),
user,
password,
database,
max: parseIntOrDefault(process.env[maxEnvVar], 10),
idleTimeoutMillis: parseIntOrDefault(process.env.DB_IDLE_TIMEOUT_MS, 30000),
};
}
export function isDatabaseConfigured() {
return Boolean(resolveDatabasePoolConfig());
}
function getPool() {
if (!pool) {
const resolved = resolveDatabasePoolConfig();
if (!resolved) {
throw new Error('Database connection settings are required');
}
pool = new Pool(resolved);
}
return pool;
}
export async function query(text, params = []) {
return getPool().query(text, params);
}
export async function withTransaction(work) {
const client = await getPool().connect();
try {
await client.query('BEGIN');
const result = await work(client);
await client.query('COMMIT');
return result;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
export async function checkDatabaseHealth() {
const result = await query('SELECT 1 AS ok');
return result.rows[0]?.ok === 1;
}
export async function closePool() {
if (pool) {
await pool.end();
pool = null;
}
}

View File

@@ -1,4 +1,5 @@
import { Pool } from 'pg';
import { resolveDatabasePoolConfig } from './db.js';
const DEFAULT_TTL_SECONDS = Number.parseInt(process.env.IDEMPOTENCY_TTL_SECONDS || '86400', 10);
const CLEANUP_EVERY_OPS = Number.parseInt(process.env.IDEMPOTENCY_CLEANUP_EVERY_OPS || '100', 10);
@@ -12,9 +13,9 @@ function shouldUseSqlStore() {
return false;
}
if (mode === 'sql') {
return true;
return Boolean(resolveDatabasePoolConfig({ preferIdempotency: true, maxEnvVar: 'IDEMPOTENCY_DB_POOL_MAX' }));
}
return Boolean(process.env.IDEMPOTENCY_DATABASE_URL);
return Boolean(resolveDatabasePoolConfig({ preferIdempotency: true, maxEnvVar: 'IDEMPOTENCY_DB_POOL_MAX' }));
}
function gcExpiredMemoryRecords(now = Date.now()) {
@@ -55,15 +56,16 @@ function createMemoryAdapter() {
}
async function createSqlAdapter() {
const connectionString = process.env.IDEMPOTENCY_DATABASE_URL;
if (!connectionString) {
throw new Error('IDEMPOTENCY_DATABASE_URL is required for sql idempotency store');
const poolConfig = resolveDatabasePoolConfig({
preferIdempotency: true,
maxEnvVar: 'IDEMPOTENCY_DB_POOL_MAX',
});
if (!poolConfig) {
throw new Error('Database connection settings are required for sql idempotency store');
}
const pool = new Pool({
connectionString,
max: Number.parseInt(process.env.IDEMPOTENCY_DB_POOL_MAX || '5', 10),
});
const pool = new Pool(poolConfig);
await pool.query(`
CREATE TABLE IF NOT EXISTS command_idempotency (

View File

@@ -6,9 +6,42 @@ import { __resetIdempotencyStoreForTests } from '../src/services/idempotency-sto
process.env.AUTH_BYPASS = 'true';
const tenantId = '11111111-1111-4111-8111-111111111111';
const businessId = '22222222-2222-4222-8222-222222222222';
const shiftId = '33333333-3333-4333-8333-333333333333';
function validOrderCreatePayload() {
return {
tenantId,
businessId,
orderNumber: 'ORD-1001',
title: 'Cafe Event Staffing',
serviceType: 'EVENT',
shifts: [
{
shiftCode: 'SHIFT-1',
title: 'Morning Shift',
startsAt: '2026-03-11T08:00:00.000Z',
endsAt: '2026-03-11T16:00:00.000Z',
requiredWorkers: 2,
roles: [
{
roleCode: 'BARISTA',
roleName: 'Barista',
workersNeeded: 2,
payRateCents: 2200,
billRateCents: 3500,
},
],
},
],
};
}
beforeEach(() => {
process.env.IDEMPOTENCY_STORE = 'memory';
delete process.env.IDEMPOTENCY_DATABASE_URL;
delete process.env.DATABASE_URL;
__resetIdempotencyStoreForTests();
});
@@ -21,34 +54,65 @@ test('GET /healthz returns healthy response', async () => {
assert.equal(typeof res.body.requestId, 'string');
});
test('GET /readyz reports database not configured when no database env is present', async () => {
const app = createApp();
const res = await request(app).get('/readyz');
assert.equal(res.status, 503);
assert.equal(res.body.ok, false);
assert.equal(res.body.status, 'DATABASE_NOT_CONFIGURED');
});
test('command route requires idempotency key', async () => {
const app = createApp();
const res = await request(app)
.post('/commands/orders/create')
.set('Authorization', 'Bearer test-token')
.send({ payload: {} });
.send(validOrderCreatePayload());
assert.equal(res.status, 400);
assert.equal(res.body.code, 'MISSING_IDEMPOTENCY_KEY');
});
test('command route is idempotent by key', async () => {
const app = createApp();
test('command route is idempotent by key and only executes handler once', async () => {
let callCount = 0;
const app = createApp({
commandHandlers: {
createOrder: async () => {
callCount += 1;
return {
orderId: '44444444-4444-4444-8444-444444444444',
orderNumber: 'ORD-1001',
status: 'OPEN',
shiftCount: 1,
shiftIds: [shiftId],
};
},
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'),
},
});
const first = await request(app)
.post('/commands/orders/create')
.set('Authorization', 'Bearer test-token')
.set('Idempotency-Key', 'abc-123')
.send({ payload: { order: 'x' } });
.send(validOrderCreatePayload());
const second = await request(app)
.post('/commands/orders/create')
.set('Authorization', 'Bearer test-token')
.set('Idempotency-Key', 'abc-123')
.send({ payload: { order: 'x' } });
.send(validOrderCreatePayload());
assert.equal(first.status, 200);
assert.equal(second.status, 200);
assert.equal(first.body.commandId, second.body.commandId);
assert.equal(callCount, 1);
assert.equal(first.body.orderId, second.body.orderId);
assert.equal(first.body.idempotencyKey, 'abc-123');
assert.equal(second.body.idempotencyKey, 'abc-123');
});

View File

@@ -0,0 +1,13 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY src ./src
ENV PORT=8080
EXPOSE 8080
CMD ["node", "src/server.js"]

3039
backend/query-api/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,23 @@
{
"name": "@krow/query-api",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": ">=20"
},
"scripts": {
"start": "node src/server.js",
"test": "node --test"
},
"dependencies": {
"express": "^4.21.2",
"firebase-admin": "^13.0.2",
"pg": "^8.20.0",
"pino": "^9.6.0",
"pino-http": "^10.3.0"
},
"devDependencies": {
"supertest": "^7.0.0"
}
}

View File

@@ -0,0 +1,30 @@
import express from 'express';
import pino from 'pino';
import pinoHttp from 'pino-http';
import { requestContext } from './middleware/request-context.js';
import { errorHandler, notFoundHandler } from './middleware/error-handler.js';
import { healthRouter } from './routes/health.js';
import { createQueryRouter } from './routes/query.js';
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
export function createApp(options = {}) {
const app = express();
app.use(requestContext);
app.use(
pinoHttp({
logger,
customProps: (req) => ({ requestId: req.requestId }),
})
);
app.use(express.json({ limit: '2mb' }));
app.use(healthRouter);
app.use('/query', createQueryRouter(options.queryService));
app.use(notFoundHandler);
app.use(errorHandler);
return app;
}

View File

@@ -0,0 +1,26 @@
export class AppError extends Error {
constructor(code, message, status = 400, details = {}) {
super(message);
this.name = 'AppError';
this.code = code;
this.status = status;
this.details = details;
}
}
export function toErrorEnvelope(error, requestId) {
const status = error?.status && Number.isInteger(error.status) ? error.status : 500;
const code = error?.code || 'INTERNAL_ERROR';
const message = error?.message || 'Unexpected error';
const details = error?.details || {};
return {
status,
body: {
code,
message,
details,
requestId,
},
};
}

View File

@@ -0,0 +1,45 @@
import { AppError } from '../lib/errors.js';
import { can } from '../services/policy.js';
import { verifyFirebaseToken } from '../services/firebase-auth.js';
function getBearerToken(header) {
if (!header) return null;
const [scheme, token] = header.split(' ');
if (!scheme || scheme.toLowerCase() !== 'bearer' || !token) return null;
return token;
}
export async function requireAuth(req, _res, next) {
try {
const token = getBearerToken(req.get('Authorization'));
if (!token) {
throw new AppError('UNAUTHENTICATED', 'Missing bearer token', 401);
}
if (process.env.AUTH_BYPASS === 'true') {
req.actor = { uid: 'test-user', email: 'test@krow.local', role: 'TEST' };
return next();
}
const decoded = await verifyFirebaseToken(token);
req.actor = {
uid: decoded.uid,
email: decoded.email || null,
role: decoded.role || null,
};
return next();
} catch (error) {
if (error instanceof AppError) return next(error);
return next(new AppError('UNAUTHENTICATED', 'Token verification failed', 401));
}
}
export function requirePolicy(action, resource) {
return (req, _res, next) => {
if (!can(action, resource, req.actor)) {
return next(new AppError('FORBIDDEN', 'Not allowed to perform this action', 403));
}
return next();
};
}

View File

@@ -0,0 +1,25 @@
import { toErrorEnvelope } from '../lib/errors.js';
export function notFoundHandler(req, res) {
res.status(404).json({
code: 'NOT_FOUND',
message: `Route not found: ${req.method} ${req.path}`,
details: {},
requestId: req.requestId,
});
}
export function errorHandler(error, req, res, _next) {
const envelope = toErrorEnvelope(error, req.requestId);
if (req.log) {
req.log.error(
{
errCode: envelope.body.code,
status: envelope.status,
details: envelope.body.details,
},
envelope.body.message
);
}
res.status(envelope.status).json(envelope.body);
}

View File

@@ -0,0 +1,9 @@
import { randomUUID } from 'node:crypto';
export function requestContext(req, res, next) {
const incoming = req.get('X-Request-Id');
req.requestId = incoming || randomUUID();
res.setHeader('X-Request-Id', req.requestId);
res.locals.startedAt = Date.now();
next();
}

View File

@@ -0,0 +1,45 @@
import { Router } from 'express';
import { checkDatabaseHealth, isDatabaseConfigured } from '../services/db.js';
export const healthRouter = Router();
function healthHandler(req, res) {
res.status(200).json({
ok: true,
service: 'krow-query-api',
version: process.env.SERVICE_VERSION || 'dev',
requestId: req.requestId,
});
}
healthRouter.get('/health', healthHandler);
healthRouter.get('/healthz', healthHandler);
healthRouter.get('/readyz', async (req, res) => {
if (!isDatabaseConfigured()) {
return res.status(503).json({
ok: false,
service: 'krow-query-api',
status: 'DATABASE_NOT_CONFIGURED',
requestId: req.requestId,
});
}
try {
const ok = await checkDatabaseHealth();
return res.status(ok ? 200 : 503).json({
ok,
service: 'krow-query-api',
status: ok ? 'READY' : 'DATABASE_UNAVAILABLE',
requestId: req.requestId,
});
} catch (error) {
return res.status(503).json({
ok: false,
service: 'krow-query-api',
status: 'DATABASE_UNAVAILABLE',
details: { message: error.message },
requestId: req.requestId,
});
}
});

View File

@@ -0,0 +1,138 @@
import { Router } from 'express';
import { AppError } from '../lib/errors.js';
import { requireAuth, requirePolicy } from '../middleware/auth.js';
import {
getAssignmentAttendance,
getOrderDetail,
getStaffReviewSummary,
listFavoriteStaff,
listOrders,
} from '../services/query-service.js';
const defaultQueryService = {
getAssignmentAttendance,
getOrderDetail,
getStaffReviewSummary,
listFavoriteStaff,
listOrders,
};
function requireUuid(value, field) {
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)) {
throw new AppError('VALIDATION_ERROR', `${field} must be a UUID`, 400, { field });
}
return value;
}
export function createQueryRouter(queryService = defaultQueryService) {
const router = Router();
router.get(
'/tenants/:tenantId/orders',
requireAuth,
requirePolicy('orders.read', 'order'),
async (req, res, next) => {
try {
const tenantId = requireUuid(req.params.tenantId, 'tenantId');
const orders = await queryService.listOrders({
tenantId,
businessId: req.query.businessId,
status: req.query.status,
limit: req.query.limit,
offset: req.query.offset,
});
return res.status(200).json({
items: orders,
requestId: req.requestId,
});
} catch (error) {
return next(error);
}
}
);
router.get(
'/tenants/:tenantId/orders/:orderId',
requireAuth,
requirePolicy('orders.read', 'order'),
async (req, res, next) => {
try {
const order = await queryService.getOrderDetail({
tenantId: requireUuid(req.params.tenantId, 'tenantId'),
orderId: requireUuid(req.params.orderId, 'orderId'),
});
return res.status(200).json({
...order,
requestId: req.requestId,
});
} catch (error) {
return next(error);
}
}
);
router.get(
'/tenants/:tenantId/businesses/:businessId/favorite-staff',
requireAuth,
requirePolicy('business.favorite-staff.read', 'staff'),
async (req, res, next) => {
try {
const items = await queryService.listFavoriteStaff({
tenantId: requireUuid(req.params.tenantId, 'tenantId'),
businessId: requireUuid(req.params.businessId, 'businessId'),
limit: req.query.limit,
offset: req.query.offset,
});
return res.status(200).json({
items,
requestId: req.requestId,
});
} catch (error) {
return next(error);
}
}
);
router.get(
'/tenants/:tenantId/staff/:staffId/review-summary',
requireAuth,
requirePolicy('staff.reviews.read', 'staff'),
async (req, res, next) => {
try {
const summary = await queryService.getStaffReviewSummary({
tenantId: requireUuid(req.params.tenantId, 'tenantId'),
staffId: requireUuid(req.params.staffId, 'staffId'),
limit: req.query.limit,
});
return res.status(200).json({
...summary,
requestId: req.requestId,
});
} catch (error) {
return next(error);
}
}
);
router.get(
'/tenants/:tenantId/assignments/:assignmentId/attendance',
requireAuth,
requirePolicy('attendance.read', 'attendance'),
async (req, res, next) => {
try {
const attendance = await queryService.getAssignmentAttendance({
tenantId: requireUuid(req.params.tenantId, 'tenantId'),
assignmentId: requireUuid(req.params.assignmentId, 'assignmentId'),
});
return res.status(200).json({
...attendance,
requestId: req.requestId,
});
} catch (error) {
return next(error);
}
}
);
return router;
}

View File

@@ -0,0 +1,9 @@
import { createApp } from './app.js';
const port = Number(process.env.PORT || 8080);
const app = createApp();
app.listen(port, () => {
// eslint-disable-next-line no-console
console.log(`krow-query-api listening on port ${port}`);
});

View File

@@ -0,0 +1,72 @@
import { Pool } from 'pg';
let pool;
function parseIntOrDefault(value, fallback) {
const parsed = Number.parseInt(`${value || fallback}`, 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
function resolveDatabasePoolConfig() {
if (process.env.DATABASE_URL) {
return {
connectionString: process.env.DATABASE_URL,
max: parseIntOrDefault(process.env.DB_POOL_MAX, 10),
idleTimeoutMillis: parseIntOrDefault(process.env.DB_IDLE_TIMEOUT_MS, 30000),
};
}
const user = process.env.DB_USER;
const password = process.env.DB_PASSWORD;
const database = process.env.DB_NAME;
const host = process.env.DB_HOST || (
process.env.INSTANCE_CONNECTION_NAME
? `/cloudsql/${process.env.INSTANCE_CONNECTION_NAME}`
: ''
);
if (!user || password == null || !database || !host) {
return null;
}
return {
host,
port: parseIntOrDefault(process.env.DB_PORT, 5432),
user,
password,
database,
max: parseIntOrDefault(process.env.DB_POOL_MAX, 10),
idleTimeoutMillis: parseIntOrDefault(process.env.DB_IDLE_TIMEOUT_MS, 30000),
};
}
export function isDatabaseConfigured() {
return Boolean(resolveDatabasePoolConfig());
}
function getPool() {
if (!pool) {
const resolved = resolveDatabasePoolConfig();
if (!resolved) {
throw new Error('Database connection settings are required');
}
pool = new Pool(resolved);
}
return pool;
}
export async function query(text, params = []) {
return getPool().query(text, params);
}
export async function checkDatabaseHealth() {
const result = await query('SELECT 1 AS ok');
return result.rows[0]?.ok === 1;
}
export async function closePool() {
if (pool) {
await pool.end();
pool = null;
}
}

View File

@@ -0,0 +1,13 @@
import { applicationDefault, getApps, initializeApp } from 'firebase-admin/app';
import { getAuth } from 'firebase-admin/auth';
function ensureAdminApp() {
if (getApps().length === 0) {
initializeApp({ credential: applicationDefault() });
}
}
export async function verifyFirebaseToken(token) {
ensureAdminApp();
return getAuth().verifyIdToken(token);
}

View File

@@ -0,0 +1,5 @@
export function can(action, resource, actor) {
void action;
void resource;
return Boolean(actor?.uid);
}

View File

@@ -0,0 +1,285 @@
import { AppError } from '../lib/errors.js';
import { query } from './db.js';
function parseLimit(value, fallback = 20, max = 100) {
const parsed = Number.parseInt(`${value || fallback}`, 10);
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
return Math.min(parsed, max);
}
function parseOffset(value) {
const parsed = Number.parseInt(`${value || 0}`, 10);
if (!Number.isFinite(parsed) || parsed < 0) return 0;
return parsed;
}
export async function listOrders({ tenantId, businessId, status, limit, offset }) {
const result = await query(
`
SELECT
o.id,
o.order_number AS "orderNumber",
o.title,
o.status,
o.service_type AS "serviceType",
o.starts_at AS "startsAt",
o.ends_at AS "endsAt",
o.location_name AS "locationName",
o.location_address AS "locationAddress",
o.created_at AS "createdAt",
b.id AS "businessId",
b.business_name AS "businessName",
v.id AS "vendorId",
v.company_name AS "vendorName",
COALESCE(COUNT(s.id), 0)::INTEGER AS "shiftCount",
COALESCE(SUM(s.required_workers), 0)::INTEGER AS "requiredWorkers",
COALESCE(SUM(s.assigned_workers), 0)::INTEGER AS "assignedWorkers"
FROM orders o
JOIN businesses b ON b.id = o.business_id
LEFT JOIN vendors v ON v.id = o.vendor_id
LEFT JOIN shifts s ON s.order_id = o.id
WHERE o.tenant_id = $1
AND ($2::uuid IS NULL OR o.business_id = $2::uuid)
AND ($3::text IS NULL OR o.status = $3::text)
GROUP BY o.id, b.id, v.id
ORDER BY o.created_at DESC
LIMIT $4 OFFSET $5
`,
[
tenantId,
businessId || null,
status || null,
parseLimit(limit),
parseOffset(offset),
]
);
return result.rows;
}
export async function getOrderDetail({ tenantId, orderId }) {
const orderResult = await query(
`
SELECT
o.id,
o.order_number AS "orderNumber",
o.title,
o.description,
o.status,
o.service_type AS "serviceType",
o.starts_at AS "startsAt",
o.ends_at AS "endsAt",
o.location_name AS "locationName",
o.location_address AS "locationAddress",
o.latitude,
o.longitude,
o.notes,
o.created_at AS "createdAt",
b.id AS "businessId",
b.business_name AS "businessName",
v.id AS "vendorId",
v.company_name AS "vendorName"
FROM orders o
JOIN businesses b ON b.id = o.business_id
LEFT JOIN vendors v ON v.id = o.vendor_id
WHERE o.tenant_id = $1
AND o.id = $2
`,
[tenantId, orderId]
);
if (orderResult.rowCount === 0) {
throw new AppError('NOT_FOUND', 'Order not found', 404, { tenantId, orderId });
}
const shiftsResult = await query(
`
SELECT
s.id,
s.shift_code AS "shiftCode",
s.title,
s.status,
s.starts_at AS "startsAt",
s.ends_at AS "endsAt",
s.timezone,
s.location_name AS "locationName",
s.location_address AS "locationAddress",
s.required_workers AS "requiredWorkers",
s.assigned_workers AS "assignedWorkers",
cp.id AS "clockPointId",
cp.label AS "clockPointLabel"
FROM shifts s
LEFT JOIN clock_points cp ON cp.id = s.clock_point_id
WHERE s.tenant_id = $1
AND s.order_id = $2
ORDER BY s.starts_at ASC
`,
[tenantId, orderId]
);
const shiftIds = shiftsResult.rows.map((row) => row.id);
let rolesByShiftId = new Map();
if (shiftIds.length > 0) {
const rolesResult = await query(
`
SELECT
sr.id,
sr.shift_id AS "shiftId",
sr.role_code AS "roleCode",
sr.role_name AS "roleName",
sr.workers_needed AS "workersNeeded",
sr.assigned_count AS "assignedCount",
sr.pay_rate_cents AS "payRateCents",
sr.bill_rate_cents AS "billRateCents"
FROM shift_roles sr
WHERE sr.shift_id = ANY($1::uuid[])
ORDER BY sr.role_name ASC
`,
[shiftIds]
);
rolesByShiftId = rolesResult.rows.reduce((map, row) => {
const list = map.get(row.shiftId) || [];
list.push(row);
map.set(row.shiftId, list);
return map;
}, new Map());
}
return {
...orderResult.rows[0],
shifts: shiftsResult.rows.map((shift) => ({
...shift,
roles: rolesByShiftId.get(shift.id) || [],
})),
};
}
export async function listFavoriteStaff({ tenantId, businessId, limit, offset }) {
const result = await query(
`
SELECT
sf.id AS "favoriteId",
sf.created_at AS "favoritedAt",
s.id AS "staffId",
s.full_name AS "fullName",
s.primary_role AS "primaryRole",
s.average_rating AS "averageRating",
s.rating_count AS "ratingCount",
s.status
FROM staff_favorites sf
JOIN staffs s ON s.id = sf.staff_id
WHERE sf.tenant_id = $1
AND sf.business_id = $2
ORDER BY sf.created_at DESC
LIMIT $3 OFFSET $4
`,
[tenantId, businessId, parseLimit(limit), parseOffset(offset)]
);
return result.rows;
}
export async function getStaffReviewSummary({ tenantId, staffId, limit }) {
const staffResult = await query(
`
SELECT
id AS "staffId",
full_name AS "fullName",
average_rating AS "averageRating",
rating_count AS "ratingCount",
primary_role AS "primaryRole",
status
FROM staffs
WHERE tenant_id = $1
AND id = $2
`,
[tenantId, staffId]
);
if (staffResult.rowCount === 0) {
throw new AppError('NOT_FOUND', 'Staff not found', 404, { tenantId, staffId });
}
const reviewsResult = await query(
`
SELECT
sr.id AS "reviewId",
sr.rating,
sr.review_text AS "reviewText",
sr.tags,
sr.created_at AS "createdAt",
b.id AS "businessId",
b.business_name AS "businessName",
sr.assignment_id AS "assignmentId"
FROM staff_reviews sr
JOIN businesses b ON b.id = sr.business_id
WHERE sr.tenant_id = $1
AND sr.staff_id = $2
ORDER BY sr.created_at DESC
LIMIT $3
`,
[tenantId, staffId, parseLimit(limit, 10, 50)]
);
return {
...staffResult.rows[0],
reviews: reviewsResult.rows,
};
}
export async function getAssignmentAttendance({ tenantId, assignmentId }) {
const assignmentResult = await query(
`
SELECT
a.id AS "assignmentId",
a.status,
a.shift_id AS "shiftId",
a.staff_id AS "staffId",
s.title AS "shiftTitle",
s.starts_at AS "shiftStartsAt",
s.ends_at AS "shiftEndsAt",
attendance_sessions.id AS "sessionId",
attendance_sessions.status AS "sessionStatus",
attendance_sessions.check_in_at AS "checkInAt",
attendance_sessions.check_out_at AS "checkOutAt",
attendance_sessions.worked_minutes AS "workedMinutes"
FROM assignments a
JOIN shifts s ON s.id = a.shift_id
LEFT JOIN attendance_sessions ON attendance_sessions.assignment_id = a.id
WHERE a.id = $1
AND a.tenant_id = $2
`,
[assignmentId, tenantId]
);
if (assignmentResult.rowCount === 0) {
throw new AppError('NOT_FOUND', 'Assignment not found', 404, { tenantId, assignmentId });
}
const eventsResult = await query(
`
SELECT
id AS "attendanceEventId",
event_type AS "eventType",
source_type AS "sourceType",
source_reference AS "sourceReference",
nfc_tag_uid AS "nfcTagUid",
latitude,
longitude,
distance_to_clock_point_meters AS "distanceToClockPointMeters",
within_geofence AS "withinGeofence",
validation_status AS "validationStatus",
validation_reason AS "validationReason",
captured_at AS "capturedAt"
FROM attendance_events
WHERE assignment_id = $1
ORDER BY captured_at ASC
`,
[assignmentId]
);
return {
...assignmentResult.rows[0],
events: eventsResult.rows,
};
}

View File

@@ -0,0 +1,126 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import request from 'supertest';
import { createApp } from '../src/app.js';
process.env.AUTH_BYPASS = 'true';
const tenantId = '11111111-1111-4111-8111-111111111111';
const orderId = '22222222-2222-4222-8222-222222222222';
const businessId = '33333333-3333-4333-8333-333333333333';
const staffId = '44444444-4444-4444-8444-444444444444';
const assignmentId = '55555555-5555-4555-8555-555555555555';
test('GET /healthz returns healthy response', async () => {
const app = createApp();
const res = await request(app).get('/healthz');
assert.equal(res.status, 200);
assert.equal(res.body.ok, true);
assert.equal(res.body.service, 'krow-query-api');
assert.equal(typeof res.body.requestId, 'string');
assert.equal(typeof res.headers['x-request-id'], 'string');
});
test('GET /readyz reports database not configured when no database env is present', async () => {
delete process.env.DATABASE_URL;
delete process.env.DB_HOST;
delete process.env.DB_NAME;
delete process.env.DB_USER;
delete process.env.DB_PASSWORD;
delete process.env.INSTANCE_CONNECTION_NAME;
const app = createApp();
const res = await request(app).get('/readyz');
assert.equal(res.status, 503);
assert.equal(res.body.status, 'DATABASE_NOT_CONFIGURED');
});
test('GET unknown route returns not found envelope', async () => {
const app = createApp();
const res = await request(app).get('/query/unknown');
assert.equal(res.status, 404);
assert.equal(res.body.code, 'NOT_FOUND');
assert.equal(typeof res.body.requestId, 'string');
});
test('GET /query/tenants/:tenantId/orders returns injected query result', async () => {
const app = createApp({
queryService: {
listOrders: async (params) => {
assert.equal(params.tenantId, tenantId);
return [{
id: orderId,
orderNumber: 'ORD-1001',
title: 'Cafe Event Staffing',
status: 'OPEN',
}];
},
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, 200);
assert.equal(res.body.items.length, 1);
assert.equal(res.body.items[0].id, orderId);
});
test('GET /query/tenants/:tenantId/assignments/:assignmentId/attendance returns injected attendance', async () => {
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 (params) => {
assert.equal(params.tenantId, tenantId);
assert.equal(params.assignmentId, assignmentId);
return {
assignmentId,
sessionStatus: 'OPEN',
events: [],
};
},
},
});
const res = await request(app)
.get(`/query/tenants/${tenantId}/assignments/${assignmentId}/attendance`)
.set('Authorization', 'Bearer test-token');
assert.equal(res.status, 200);
assert.equal(res.body.assignmentId, assignmentId);
assert.equal(res.body.sessionStatus, 'OPEN');
});
test('GET /query/tenants/:tenantId/businesses/:businessId/favorite-staff validates auth and handler wiring', async () => {
const app = createApp({
queryService: {
listOrders: async () => assert.fail('listOrders should not be called'),
getOrderDetail: async () => assert.fail('getOrderDetail should not be called'),
listFavoriteStaff: async (params) => {
assert.equal(params.tenantId, tenantId);
assert.equal(params.businessId, businessId);
return [{ staffId, fullName: 'Ana Barista' }];
},
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}/businesses/${businessId}/favorite-staff`)
.set('Authorization', 'Bearer test-token');
assert.equal(res.status, 200);
assert.equal(res.body.items[0].staffId, staffId);
});