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:
@@ -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",
|
||||
|
||||
348
backend/command-api/scripts/live-smoke-v2.mjs
Normal file
348
backend/command-api/scripts/live-smoke-v2.mjs
Normal 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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
69
backend/command-api/scripts/migrate-v2-schema.mjs
Normal file
69
backend/command-api/scripts/migrate-v2-schema.mjs
Normal 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();
|
||||
}
|
||||
600
backend/command-api/scripts/seed-v2-demo-data.mjs
Normal file
600
backend/command-api/scripts/seed-v2-demo-data.mjs
Normal 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);
|
||||
});
|
||||
162
backend/command-api/scripts/v2-demo-fixture.mjs
Normal file
162
backend/command-api/scripts/v2-demo-fixture.mjs
Normal 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',
|
||||
},
|
||||
},
|
||||
};
|
||||
639
backend/command-api/sql/v2/001_v2_domain_foundation.sql
Normal file
639
backend/command-api/sql/v2/001_v2_domain_foundation.sql
Normal 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);
|
||||
@@ -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);
|
||||
|
||||
14
backend/command-api/src/contracts/commands/attendance.js
Normal file
14
backend/command-api/src/contracts/commands/attendance.js
Normal 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(),
|
||||
});
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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(),
|
||||
});
|
||||
57
backend/command-api/src/contracts/commands/order-create.js
Normal file
57
backend/command-api/src/contracts/commands/order-create.js
Normal 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),
|
||||
});
|
||||
35
backend/command-api/src/contracts/commands/order-update.js
Normal file
35
backend/command-api/src/contracts/commands/order-update.js
Normal 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: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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(),
|
||||
});
|
||||
11
backend/command-api/src/contracts/commands/staff-review.js
Normal file
11
backend/command-api/src/contracts/commands/staff-review.js
Normal 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(),
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
1553
backend/command-api/src/services/command-service.js
Normal file
1553
backend/command-api/src/services/command-service.js
Normal file
File diff suppressed because it is too large
Load Diff
94
backend/command-api/src/services/db.js
Normal file
94
backend/command-api/src/services/db.js
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
13
backend/query-api/Dockerfile
Normal file
13
backend/query-api/Dockerfile
Normal 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
3039
backend/query-api/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
backend/query-api/package.json
Normal file
23
backend/query-api/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
30
backend/query-api/src/app.js
Normal file
30
backend/query-api/src/app.js
Normal 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;
|
||||
}
|
||||
26
backend/query-api/src/lib/errors.js
Normal file
26
backend/query-api/src/lib/errors.js
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
45
backend/query-api/src/middleware/auth.js
Normal file
45
backend/query-api/src/middleware/auth.js
Normal 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();
|
||||
};
|
||||
}
|
||||
25
backend/query-api/src/middleware/error-handler.js
Normal file
25
backend/query-api/src/middleware/error-handler.js
Normal 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);
|
||||
}
|
||||
9
backend/query-api/src/middleware/request-context.js
Normal file
9
backend/query-api/src/middleware/request-context.js
Normal 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();
|
||||
}
|
||||
45
backend/query-api/src/routes/health.js
Normal file
45
backend/query-api/src/routes/health.js
Normal 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,
|
||||
});
|
||||
}
|
||||
});
|
||||
138
backend/query-api/src/routes/query.js
Normal file
138
backend/query-api/src/routes/query.js
Normal 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;
|
||||
}
|
||||
9
backend/query-api/src/server.js
Normal file
9
backend/query-api/src/server.js
Normal 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}`);
|
||||
});
|
||||
72
backend/query-api/src/services/db.js
Normal file
72
backend/query-api/src/services/db.js
Normal 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;
|
||||
}
|
||||
}
|
||||
13
backend/query-api/src/services/firebase-auth.js
Normal file
13
backend/query-api/src/services/firebase-auth.js
Normal 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);
|
||||
}
|
||||
5
backend/query-api/src/services/policy.js
Normal file
5
backend/query-api/src/services/policy.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export function can(action, resource, actor) {
|
||||
void action;
|
||||
void resource;
|
||||
return Boolean(actor?.uid);
|
||||
}
|
||||
285
backend/query-api/src/services/query-service.js
Normal file
285
backend/query-api/src/services/query-service.js
Normal 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,
|
||||
};
|
||||
}
|
||||
126
backend/query-api/test/app.test.js
Normal file
126
backend/query-api/test/app.test.js
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user