feat(backend): add foundation services and sql idempotency

This commit is contained in:
zouantchaw
2026-02-23 22:27:40 -05:00
parent e81eab1165
commit f8f81ec77c
40 changed files with 7416 additions and 0 deletions

View File

@@ -0,0 +1,30 @@
import express from 'express';
import pino from 'pino';
import pinoHttp from 'pino-http';
import { requestContext } from './middleware/request-context.js';
import { errorHandler, notFoundHandler } from './middleware/error-handler.js';
import { healthRouter } from './routes/health.js';
import { createCommandsRouter } from './routes/commands.js';
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
export function createApp() {
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('/commands', createCommandsRouter());
app.use(notFoundHandler);
app.use(errorHandler);
return app;
}

View File

@@ -0,0 +1,6 @@
import { z } from 'zod';
export const commandBaseSchema = z.object({
payload: z.record(z.any()).optional(),
metadata: z.record(z.any()).optional(),
});

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
import { AppError } from '../lib/errors.js';
export function requireIdempotencyKey(req, _res, next) {
const idempotencyKey = req.get('Idempotency-Key');
if (!idempotencyKey) {
return next(new AppError('MISSING_IDEMPOTENCY_KEY', 'Missing Idempotency-Key header', 400));
}
req.idempotencyKey = idempotencyKey;
return next();
}

View File

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

View File

@@ -0,0 +1,113 @@
import { Router } from 'express';
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';
function parseBody(body) {
const parsed = commandBaseSchema.safeParse(body || {});
if (!parsed.success) {
throw new AppError('VALIDATION_ERROR', 'Invalid command payload', 400, {
issues: parsed.error.issues,
});
}
return parsed.data;
}
function createCommandResponse(route, requestId, idempotencyKey) {
return {
accepted: true,
route,
commandId: `${route}:${Date.now()}`,
idempotencyKey,
requestId,
};
}
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() {
const router = Router();
router.post(
'/orders/create',
requireAuth,
requireIdempotencyKey,
requirePolicy('orders.create', 'order'),
buildCommandHandler('orders.create', 'order')
);
router.post(
'/orders/:orderId/update',
requireAuth,
requireIdempotencyKey,
requirePolicy('orders.update', 'order'),
buildCommandHandler('orders.update', 'order')
);
router.post(
'/orders/:orderId/cancel',
requireAuth,
requireIdempotencyKey,
requirePolicy('orders.cancel', 'order'),
buildCommandHandler('orders.cancel', 'order')
);
router.post(
'/shifts/:shiftId/change-status',
requireAuth,
requireIdempotencyKey,
requirePolicy('shifts.change-status', 'shift'),
buildCommandHandler('shifts.change-status', 'shift')
);
router.post(
'/shifts/:shiftId/assign-staff',
requireAuth,
requireIdempotencyKey,
requirePolicy('shifts.assign-staff', 'shift'),
buildCommandHandler('shifts.assign-staff', 'shift')
);
router.post(
'/shifts/:shiftId/accept',
requireAuth,
requireIdempotencyKey,
requirePolicy('shifts.accept', 'shift'),
buildCommandHandler('shifts.accept', 'shift')
);
return router;
}

View File

@@ -0,0 +1,12 @@
import { Router } from 'express';
export const healthRouter = Router();
healthRouter.get('/healthz', (req, res) => {
res.status(200).json({
ok: true,
service: 'krow-command-api',
version: process.env.SERVICE_VERSION || 'dev',
requestId: req.requestId,
});
});

View File

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

View File

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

View File

@@ -0,0 +1,208 @@
import { Pool } from 'pg';
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);
const memoryStore = new Map();
let adapterPromise = null;
function shouldUseSqlStore() {
const mode = (process.env.IDEMPOTENCY_STORE || '').toLowerCase();
if (mode === 'memory') {
return false;
}
if (mode === 'sql') {
return true;
}
return Boolean(process.env.IDEMPOTENCY_DATABASE_URL);
}
function gcExpiredMemoryRecords(now = Date.now()) {
for (const [key, value] of memoryStore.entries()) {
if (value.expiresAt <= now) {
memoryStore.delete(key);
}
}
}
function createMemoryAdapter() {
return {
async read(compositeKey) {
gcExpiredMemoryRecords();
return memoryStore.get(compositeKey) || null;
},
async write({
compositeKey,
payload,
statusCode = 200,
}) {
const now = Date.now();
const existing = memoryStore.get(compositeKey);
if (existing && existing.expiresAt > now) {
return existing;
}
const record = {
payload,
statusCode,
createdAt: now,
expiresAt: now + (DEFAULT_TTL_SECONDS * 1000),
};
memoryStore.set(compositeKey, record);
return record;
},
};
}
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 pool = new Pool({
connectionString,
max: Number.parseInt(process.env.IDEMPOTENCY_DB_POOL_MAX || '5', 10),
});
await pool.query(`
CREATE TABLE IF NOT EXISTS command_idempotency (
composite_key TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
route TEXT NOT NULL,
idempotency_key TEXT NOT NULL,
status_code INTEGER NOT NULL,
response_payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL
);
`);
await pool.query(`
CREATE INDEX IF NOT EXISTS idx_command_idempotency_expires_at
ON command_idempotency (expires_at);
`);
let opCount = 0;
async function maybeCleanupExpiredRows() {
opCount += 1;
if (CLEANUP_EVERY_OPS <= 0 || opCount % CLEANUP_EVERY_OPS !== 0) {
return;
}
await pool.query('DELETE FROM command_idempotency WHERE expires_at <= NOW()');
}
function mapRow(row) {
return {
statusCode: row.status_code,
payload: row.response_payload,
};
}
return {
async read(compositeKey) {
await maybeCleanupExpiredRows();
const result = await pool.query(
`
SELECT status_code, response_payload
FROM command_idempotency
WHERE composite_key = $1
AND expires_at > NOW()
`,
[compositeKey]
);
if (result.rowCount === 0) {
return null;
}
return mapRow(result.rows[0]);
},
async write({
compositeKey,
userId,
route,
idempotencyKey,
payload,
statusCode = 200,
}) {
await maybeCleanupExpiredRows();
const expiresAt = new Date(Date.now() + (DEFAULT_TTL_SECONDS * 1000));
const payloadJson = JSON.stringify(payload);
await pool.query(
`
INSERT INTO command_idempotency (
composite_key,
user_id,
route,
idempotency_key,
status_code,
response_payload,
expires_at
)
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7)
ON CONFLICT (composite_key) DO NOTHING
`,
[compositeKey, userId, route, idempotencyKey, statusCode, payloadJson, expiresAt]
);
const existingResult = await pool.query(
`
SELECT status_code, response_payload
FROM command_idempotency
WHERE composite_key = $1
AND expires_at > NOW()
`,
[compositeKey]
);
if (existingResult.rowCount === 0) {
throw new Error('Idempotency write failed to persist or recover existing record');
}
return mapRow(existingResult.rows[0]);
},
};
}
async function getAdapter() {
if (!adapterPromise) {
adapterPromise = shouldUseSqlStore()
? createSqlAdapter()
: Promise.resolve(createMemoryAdapter());
}
return adapterPromise;
}
export function buildIdempotencyKey({ userId, route, idempotencyKey }) {
return `${userId}:${route}:${idempotencyKey}`;
}
export async function readIdempotentResult(compositeKey) {
const adapter = await getAdapter();
return adapter.read(compositeKey);
}
export async function writeIdempotentResult({
compositeKey,
userId,
route,
idempotencyKey,
payload,
statusCode = 200,
}) {
const adapter = await getAdapter();
return adapter.write({
compositeKey,
userId,
route,
idempotencyKey,
payload,
statusCode,
});
}
export function __resetIdempotencyStoreForTests() {
memoryStore.clear();
adapterPromise = null;
}

View File

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