seahaven-ap/packages/api/src/routes/departments.ts
Adam Moussa 864531b51e
feat(api): portal contract, cookie auth, and domain stubs (AP-51) (#64)
* feat(api): serve portal-shaped health and error envelope

Move liveness to GET /api/health { stage, sha } with a Node 24 image on 8080 so ALB probes and deploy verify do not need auth or a database ping.

* feat(api): switch live auth to host cookie BFF

Replace Bearer as the documented session path with Cognito hosted UI plus __Host-ap_* cookies so the SPA can call /api with credentials include.

* feat(web): add unused cookie SPA API client

Land a credentials-include fetch helper and hand-synced health/me types without wiring pages or domain hooks, so mocks stay the default data path.

* feat(api): add master-data OpenAPI and Hono stubs

* feat(api): add invoice, line, and document stubs

* feat(api): add approval policy, inbox, and activity stubs

* test(web): fix SPA client fetch mock types

* test(web): cast fetch mock call args for tsc

* fix(api): do not default DEV_AUTH_BYPASS outside local migrate

* fix(api): replace invoice lines in a single transaction

* fix(api): create invoices and lines in one transaction

* fix(api): inline GIT_SHA from the image build arg

* fix(api): stop PATCH from skipping the approval workflow

* fix(api): address review feedback

* fix(ci): format upsert-user test

* fix(api): document only the auth statuses the routes return

* fix(api): drop health 400 responses the routes never return
2026-09-25 22:43:44 +00:00

80 lines
3 KiB
TypeScript

import { eq } from "drizzle-orm";
import { Hono } from "hono";
import type { Db } from "../db/client.js";
import { departments } from "../db/schema/index.js";
import type { AppBindings } from "../auth/middleware.js";
import { errorJson } from "../http.js";
import { asString, iso, isUuid, parseJsonBody, requireCan } from "./helpers.js";
function toDepartment(row: typeof departments.$inferSelect) {
return {
id: row.id,
code: row.code,
name: row.name,
createdAt: iso(row.createdAt),
updatedAt: iso(row.updatedAt),
};
}
export function createDepartmentRoutes(handle: Db) {
const routes = new Hono<AppBindings>();
routes.get("/departments", async (c) => {
const denied = requireCan(c, "read:invoices");
if (denied) return denied;
const rows = await handle.db.select().from(departments);
return c.json({ items: rows.map(toDepartment) });
});
routes.get("/departments/:id", async (c) => {
const denied = requireCan(c, "read:invoices");
if (denied) return denied;
const id = c.req.param("id");
if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid department id.");
const row = await handle.db.query.departments.findFirst({ where: eq(departments.id, id) });
if (!row) return errorJson(c, 404, "NOT_FOUND", "Department not found.");
return c.json(toDepartment(row));
});
routes.post("/departments", async (c) => {
const denied = requireCan(c, "admin:settings");
if (denied) return denied;
const body = await parseJsonBody(c);
const code = asString(body.code).trim();
const name = asString(body.name).trim();
if (!code || !name) return errorJson(c, 400, "VALIDATION_ERROR", "Code and name are required.");
const [row] = await handle.db.insert(departments).values({ code, name }).returning();
return c.json(toDepartment(row), 201);
});
routes.patch("/departments/:id", async (c) => {
const denied = requireCan(c, "admin:settings");
if (denied) return denied;
const id = c.req.param("id");
if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid department id.");
const existing = await handle.db.query.departments.findFirst({
where: eq(departments.id, id),
});
if (!existing) return errorJson(c, 404, "NOT_FOUND", "Department not found.");
const body = await parseJsonBody(c);
const patch: Partial<typeof departments.$inferInsert> = { updatedAt: new Date() };
if (body.code !== undefined) {
const code = asString(body.code).trim();
if (!code) return errorJson(c, 400, "VALIDATION_ERROR", "Code is required.");
patch.code = code;
}
if (body.name !== undefined) {
const name = asString(body.name).trim();
if (!name) return errorJson(c, 400, "VALIDATION_ERROR", "Name is required.");
patch.name = name;
}
const [row] = await handle.db
.update(departments)
.set(patch)
.where(eq(departments.id, id))
.returning();
return c.json(toDepartment(row));
});
return routes;
}