Initial commit: add .gitignore and README
Some checks failed
CI / lint-and-test (push) Has been cancelled

This commit is contained in:
defiQUG
2026-02-09 21:51:50 -08:00
commit 93df3c8c20
116 changed files with 10080 additions and 0 deletions

36
apps/api/package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "@sankofa/api",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc",
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js",
"test": "vitest run",
"lint": "eslint src --ext .ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.700.0",
"@aws-sdk/s3-request-presigner": "^3.700.0",
"@fastify/cors": "^10.0.0",
"@fastify/jwt": "^9.0.0",
"@fastify/multipart": "^9.0.0",
"@fastify/sensible": "^6.0.0",
"@fastify/swagger": "^9.7.0",
"@fastify/swagger-ui": "^5.2.5",
"@sankofa/auth": "workspace:*",
"@sankofa/schema": "workspace:*",
"@sankofa/workflow": "workspace:*",
"drizzle-orm": "^0.36.0",
"fastify": "^5.1.0",
"yaml": "^2.8.2"
},
"devDependencies": {
"@types/node": "^22.10.0",
"eslint": "^9.15.0",
"tsx": "^4.19.0",
"typescript": "^5.7.0",
"vitest": "^2.1.0"
}
}

31
apps/api/src/audit.ts Normal file
View File

@@ -0,0 +1,31 @@
import type { FastifyRequest } from "fastify";
import { auditEvents } from "@sankofa/schema";
export interface AuditPayload {
orgId: string;
actorId?: string;
actorEmail?: string;
action: string;
resourceType: string;
resourceId: string;
beforeState?: Record<string, unknown>;
afterState?: Record<string, unknown>;
}
export function getActorFromRequest(req: FastifyRequest): { actorId?: string; actorEmail?: string } {
const user = (req as unknown as { user?: { sub?: string; email?: string } }).user;
return { actorId: user?.sub, actorEmail: user?.email ?? (req.headers["x-user-email"] as string) };
}
export async function writeAudit(db: ReturnType<typeof import("@sankofa/schema").getDb>, payload: AuditPayload) {
await db.insert(auditEvents).values({
orgId: payload.orgId,
actorId: payload.actorId ?? null,
actorEmail: payload.actorEmail ?? null,
action: payload.action,
resourceType: payload.resourceType,
resourceId: payload.resourceId,
beforeState: payload.beforeState ?? null,
afterState: payload.afterState ?? null,
});
}

70
apps/api/src/auth.ts Normal file
View File

@@ -0,0 +1,70 @@
import type { FastifyInstance, FastifyRequest } from "fastify";
import { hasPermission, hasAnyPermission, type RoleName, type Permission } from "@sankofa/auth";
const ORG_HEADER = "x-org-id";
const ROLES_HEADER = "x-roles";
export async function authPlugin(app: FastifyInstance) {
app.decorate("orgId", (req: FastifyRequest): string => {
const h = (req.headers[ORG_HEADER] as string) || "";
return h || "default";
});
app.decorate("getRoles", (req: FastifyRequest): RoleName[] => {
const header = (req.headers[ROLES_HEADER] as string) || "";
if (header) return header.split(",").map((r) => r.trim() as RoleName).filter(Boolean);
const payload = (req as unknown as { user?: { roles?: string[] } }).user;
return (payload?.roles as RoleName[]) ?? [];
});
app.decorate("vendorId", (req: FastifyRequest): string | null => {
const payload = (req as unknown as { user?: { vendorId?: string } }).user;
return payload?.vendorId ?? null;
});
app.decorate("requirePermission", (permission: Permission) => async (req: FastifyRequest) => {
const payload = (req as unknown as { user?: { sub?: string } }).user;
if (!payload?.sub) throw app.httpErrors.unauthorized("Authentication required");
const roles = app.getRoles(req);
if (!hasPermission(roles, permission)) throw app.httpErrors.forbidden("Insufficient permission");
});
app.decorate("requireAnyPermission", (permissions: Permission[]) => async (req: FastifyRequest) => {
const payload = (req as unknown as { user?: { sub?: string } }).user;
if (!payload?.sub) throw app.httpErrors.unauthorized("Authentication required");
const roles = app.getRoles(req);
if (!hasAnyPermission(roles, permissions)) throw app.httpErrors.forbidden("Insufficient permission");
});
app.addHook("preHandler", async (req) => {
try {
await req.jwtVerify();
(req as unknown as { user?: unknown }).user = (req as unknown as { user: unknown }).user ?? {};
} catch {
// Optional JWT
}
});
app.addHook("preHandler", async (req) => {
const permission = (req.routeOptions.config as { permission?: Permission } | undefined)?.permission;
if (!permission) return;
const payload = (req as unknown as { user?: { sub?: string } }).user;
if (!payload?.sub) throw app.httpErrors.unauthorized("Authentication required");
const roles = app.getRoles(req);
if (!hasPermission(roles, permission)) throw app.httpErrors.forbidden("Insufficient permission");
});
}
declare module "fastify" {
interface FastifyInstance {
orgId: (req: FastifyRequest) => string;
getRoles: (req: FastifyRequest) => RoleName[];
vendorId: (req: FastifyRequest) => string | null;
requirePermission: (permission: Permission) => (req: FastifyRequest) => Promise<void>;
requireAnyPermission: (permissions: Permission[]) => (req: FastifyRequest) => Promise<void>;
db: ReturnType<typeof import("@sankofa/schema").getDb>;
}
interface FastifyContextConfig {
permission?: Permission;
}
}

View File

@@ -0,0 +1,7 @@
import { describe, it, expect } from "vitest";
describe("api", () => {
it("placeholder", () => {
expect(1).toBe(1);
});
});

69
apps/api/src/index.ts Normal file
View File

@@ -0,0 +1,69 @@
import { fileURLToPath } from "node:url";
import Fastify from "fastify";
import cors from "@fastify/cors";
import jwt from "@fastify/jwt";
import multipart from "@fastify/multipart";
import sensible from "@fastify/sensible";
import { getDb } from "@sankofa/schema";
import { authPlugin } from "./auth.js";
import { registerV1Routes } from "./routes/v1/index.js";
import { errorCodes, type ApiErrorPayload } from "./schemas/errors.js";
import { openApiSpec } from "./openapi-spec.js";
const PORT = Number(process.env.API_PORT) || 4000;
const HOST = process.env.API_HOST || "0.0.0";
const statusToCode: Record<number, string> = {
400: errorCodes.BAD_REQUEST,
401: errorCodes.UNAUTHORIZED,
403: errorCodes.FORBIDDEN,
404: errorCodes.NOT_FOUND,
409: errorCodes.CONFLICT,
};
export async function buildApp() {
const app = Fastify({ logger: false });
await app.register(cors, { origin: true });
await app.register(sensible);
await app.register(jwt, {
secret: process.env.JWT_SECRET || "dev-secret-change-in-production",
});
await app.register(multipart, { limits: { fileSize: 50 * 1024 * 1024 } });
app.decorate("db", getDb());
await app.register(authPlugin);
await app.register(registerV1Routes, { prefix: "/api/v1" });
app.get("/health", async () => ({ status: "ok" }));
app.get("/api/openapi.json", async (_req, reply) => reply.type("application/json").send(openApiSpec));
app.get("/api/docs", async (_req, reply) => {
reply.type("text/html").send(`
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"/><title>Sankofa API</title><link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css"/></head>
<body><div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>SwaggerUIBundle({ url: '/api/openapi.json', dom_id: '#swagger-ui' });</script>
</body>
</html>`);
});
app.setErrorHandler((err: { statusCode?: number; message?: string; validation?: unknown }, _req, reply) => {
const status = err.statusCode ?? 500;
const payload: ApiErrorPayload = {
error: err.message ?? "Internal Server Error",
code: statusToCode[status] ?? "INTERNAL_ERROR",
};
if (err.validation) payload.details = err.validation;
return reply.status(status).send(payload);
});
return app;
}
async function main() {
const app = await buildApp();
await app.listen({ port: PORT, host: HOST });
}
const isMain = process.argv[1] === fileURLToPath(import.meta.url);
if (isMain) main().catch((err) => { console.error(err); process.exit(1); });

View File

@@ -0,0 +1,2 @@
export interface ProxmoxNode { node: string; status?: string; }
export async function listProxmoxNodes(_baseUrl: string, _token: string): Promise<ProxmoxNode[]> { return []; }

View File

@@ -0,0 +1,2 @@
export interface RedfishSystem { id: string; serialNumber?: string; }
export async function getRedfishSystem(_baseUrl: string, _token: string, _systemId: string): Promise<RedfishSystem | null> { return null; }

View File

@@ -0,0 +1,26 @@
export interface UnifiDevice {
id: string;
name: string;
model?: string;
generation?: string;
supportHorizon?: string;
}
export async function listUnifiDevices(_baseUrl: string, _token: string): Promise<UnifiDevice[]> {
return [];
}
export type CatalogRow = { sku: string; modelName: string; generation: string; supportHorizon: string | null };
export function enrichDevicesWithCatalog(
devices: UnifiDevice[],
catalog: CatalogRow[]
): UnifiDevice[] {
const bySku = new Map(catalog.map((c) => [c.sku, c]));
const byModel = new Map(catalog.map((c) => [c.modelName, c]));
return devices.map((d) => {
const match = (d.model && bySku.get(d.model)) || (d.model && byModel.get(d.model));
if (!match) return d;
return { ...d, generation: match.generation, supportHorizon: match.supportHorizon ?? undefined };
});
}

View File

@@ -0,0 +1,20 @@
import { readFileSync } from "fs";
import { join } from "path";
import { parse } from "yaml";
function loadSpec(): Record<string, unknown> {
try {
const path = join(process.cwd(), "docs", "openapi.yaml");
const raw = readFileSync(path, "utf8");
return parse(raw) as Record<string, unknown>;
} catch {
return {
openapi: "3.0.3",
info: { title: "Sankofa HW Infra API", version: "0.1.0" },
servers: [{ url: "/api/v1" }],
paths: {},
};
}
}
export const openApiSpec = loadSpec();

View File

@@ -0,0 +1,30 @@
import type { FastifyInstance } from "fastify";
import { eq, and } from "drizzle-orm";
import { assetComponents as acTable, assets as assetsTable } from "@sankofa/schema";
export async function assetComponentsRoutes(app: FastifyInstance) {
const db = app.db;
app.get("/", async (req, reply) => {
const list = await db.select().from(acTable);
return reply.send(list);
});
app.get<{ Params: { assetId: string } }>("/by-parent/:assetId", async (req, reply) => {
const orgId = app.orgId(req);
const [parent] = await db.select().from(assetsTable).where(and(eq(assetsTable.id, req.params.assetId), eq(assetsTable.orgId, orgId)));
if (!parent) return reply.notFound();
const list = await db.select().from(acTable).where(eq(acTable.parentAssetId, req.params.assetId));
return reply.send(list);
});
app.post<{ Body: { parentAssetId: string; childAssetId: string; role: string; slotIndex?: number } }>("/", async (req, reply) => {
const orgId = app.orgId(req);
const [parent] = await db.select().from(assetsTable).where(and(eq(assetsTable.id, req.body.parentAssetId), eq(assetsTable.orgId, orgId)));
if (!parent) return reply.notFound();
const [inserted] = await db.insert(acTable).values({ parentAssetId: req.body.parentAssetId, childAssetId: req.body.childAssetId, role: req.body.role, slotIndex: req.body.slotIndex ?? null }).returning();
return reply.code(201).send(inserted);
});
app.delete<{ Params: { id: string } }>("/:id", async (req, reply) => {
const [deleted] = await db.delete(acTable).where(eq(acTable.id, req.params.id)).returning({ id: acTable.id });
if (!deleted) return reply.notFound();
return reply.code(204).send();
});
}

View File

@@ -0,0 +1,90 @@
import type { FastifyInstance } from "fastify";
import { eq, and } from "drizzle-orm";
import { assets as assetsTable } from "@sankofa/schema";
export async function assetsRoutes(app: FastifyInstance) {
const db = app.db;
app.get("/", async (req, reply) => {
const orgId = app.orgId(req);
const list = await db.select().from(assetsTable).where(eq(assetsTable.orgId, orgId));
return reply.send(list);
});
app.get<{ Params: { id: string } }>("/:id", async (req, reply) => {
const orgId = app.orgId(req);
const [row] = await db
.select()
.from(assetsTable)
.where(and(eq(assetsTable.id, req.params.id), eq(assetsTable.orgId, orgId)));
if (!row) return reply.notFound();
return reply.send(row);
});
app.post<{
Body: {
assetId: string;
category: string;
manufacturerSerial?: string;
serviceTag?: string;
partNumber?: string;
condition?: string;
warranty?: string;
siteId?: string;
projectId?: string;
sensitivityTier?: string;
};
}>("/", async (req, reply) => {
const orgId = app.orgId(req);
const [inserted] = await db
.insert(assetsTable)
.values({
orgId,
assetId: req.body.assetId,
category: req.body.category,
manufacturerSerial: req.body.manufacturerSerial ?? null,
serviceTag: req.body.serviceTag ?? null,
partNumber: req.body.partNumber ?? null,
condition: req.body.condition ?? null,
warranty: req.body.warranty ?? null,
siteId: req.body.siteId ?? null,
projectId: req.body.projectId ?? null,
sensitivityTier: req.body.sensitivityTier ?? null,
})
.returning();
return reply.code(201).send(inserted);
});
app.patch<{
Params: { id: string };
Body: Partial<{
assetId: string;
category: string;
status: string;
siteId: string;
positionId: string;
ownerId: string;
projectId: string;
sensitivityTier: string;
}>;
}>("/:id", async (req, reply) => {
const orgId = app.orgId(req);
const [updated] = await db
.update(assetsTable)
.set({ ...req.body, updatedAt: new Date() })
.where(and(eq(assetsTable.id, req.params.id), eq(assetsTable.orgId, orgId)))
.returning();
if (!updated) return reply.notFound();
return reply.send(updated);
});
app.delete<{ Params: { id: string } }>("/:id", async (req, reply) => {
const orgId = app.orgId(req);
const [deleted] = await db
.delete(assetsTable)
.where(and(eq(assetsTable.id, req.params.id), eq(assetsTable.orgId, orgId)))
.returning({ id: assetsTable.id });
if (!deleted) return reply.notFound();
return reply.code(204).send();
});
}

View File

@@ -0,0 +1,25 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { buildApp } from "../../index.js";
describe("auth", () => {
let app: Awaited<ReturnType<typeof buildApp>>;
beforeAll(async () => {
app = await buildApp();
});
afterAll(async () => {
await app.close();
});
it("POST /api/v1/auth/token with unknown email returns 401 or 500 when DB unavailable", async () => {
const res = await app.inject({
method: "POST",
url: "/api/v1/auth/token",
headers: { "content-type": "application/json" },
payload: { email: "nobody@example.com" },
});
expect([401, 500]).toContain(res.statusCode);
expect(JSON.parse(res.payload).error).toBeDefined();
});
});

View File

@@ -0,0 +1,42 @@
import type { FastifyInstance } from "fastify";
import { eq, and } from "drizzle-orm";
import { users as usersTable, userRoles, roles as rolesTable } from "@sankofa/schema";
import type { RoleName } from "@sankofa/auth";
export async function authRoutes(app: FastifyInstance) {
const db = app.db;
app.post<{
Body: { email: string; password?: string };
}>(
"/token",
{
schema: {
body: {
type: "object",
required: ["email"],
properties: { email: { type: "string", format: "email" }, password: { type: "string" } },
},
response: { 200: { type: "object", properties: { token: { type: "string" }, user: { type: "object" } } } },
},
},
async (req, reply) => {
const orgId = (req.headers["x-org-id"] as string) || "default";
const { email } = req.body;
const [user] = await db.select().from(usersTable).where(and(eq(usersTable.email, email), eq(usersTable.orgId, orgId)));
if (!user) return reply.code(401).send({ error: "Invalid email or password", code: "UNAUTHORIZED" });
const ur = await db.select({ roleName: rolesTable.name }).from(userRoles).innerJoin(rolesTable, eq(userRoles.roleId, rolesTable.id)).where(eq(userRoles.userId, user.id));
const roleNames = ur.map((r) => r.roleName as RoleName).filter(Boolean);
const token = app.jwt.sign({
sub: user.id,
email: user.email,
roles: roleNames,
vendorId: user.vendorId ?? undefined,
orgId: user.orgId,
});
return reply.send({ token, user: { id: user.id, email: user.email, name: user.name, roles: roleNames, vendorId: user.vendorId ?? null } });
}
);
}

View File

@@ -0,0 +1,89 @@
import type { FastifyInstance } from "fastify";
import { eq, and, inArray } from "drizzle-orm";
import {
assets as assetsTable,
sites as sitesTable,
rooms as roomsTable,
rows as rowsTable,
racks as racksTable,
positions as positionsTable,
} from "@sankofa/schema";
export async function capacityRoutes(app: FastifyInstance) {
const db = app.db;
app.get<{ Params: { siteId: string } }>("/sites/:siteId", async (req, reply) => {
const orgId = app.orgId(req);
const siteId = req.params.siteId;
const [site] = await db.select().from(sitesTable).where(and(eq(sitesTable.id, siteId), eq(sitesTable.orgId, orgId)));
if (!site) return reply.notFound();
const rooms = await db.select({ id: roomsTable.id }).from(roomsTable).where(eq(roomsTable.siteId, siteId));
const roomIds = rooms.map((r) => r.id);
if (roomIds.length === 0) return reply.send({ siteId, usedRu: 0, totalRu: 0, utilizationPercent: 0 });
const rows = await db.select({ id: rowsTable.id }).from(rowsTable).where(inArray(rowsTable.roomId, roomIds));
const rowIds = rows.map((r) => r.id);
if (rowIds.length === 0) return reply.send({ siteId, usedRu: 0, totalRu: 0, utilizationPercent: 0 });
const racks = await db.select({ id: racksTable.id, ruTotal: racksTable.ruTotal }).from(racksTable).where(inArray(racksTable.rowId, rowIds));
const totalRu = racks.reduce((sum, r) => sum + r.ruTotal, 0);
const rackIds = racks.map((r) => r.id);
if (rackIds.length === 0) return reply.send({ siteId, usedRu: 0, totalRu: 0, utilizationPercent: 0 });
const positions = await db.select({ id: positionsTable.id, ruStart: positionsTable.ruStart, ruEnd: positionsTable.ruEnd }).from(positionsTable).where(inArray(positionsTable.rackId, rackIds));
const occupiedPositionIds = await db.select({ positionId: assetsTable.positionId }).from(assetsTable).where(and(eq(assetsTable.orgId, orgId), eq(assetsTable.siteId, siteId)));
const occupiedSet = new Set(occupiedPositionIds.map((a) => a.positionId).filter(Boolean));
const usedRu = positions.filter((p) => occupiedSet.has(p.id)).reduce((sum, p) => sum + (p.ruEnd - p.ruStart + 1), 0);
const utilizationPercent = totalRu > 0 ? Math.round((usedRu / totalRu) * 100) : 0;
return reply.send({ siteId, usedRu, totalRu, utilizationPercent });
});
app.get<{ Params: { siteId: string } }>("/sites/:siteId/power", async (req, reply) => {
const orgId = app.orgId(req);
const siteId = req.params.siteId;
const [site] = await db.select().from(sitesTable).where(and(eq(sitesTable.id, siteId), eq(sitesTable.orgId, orgId)));
if (!site) return reply.notFound();
const rooms = await db.select({ id: roomsTable.id }).from(roomsTable).where(eq(roomsTable.siteId, siteId));
const roomIds = rooms.map((r) => r.id);
if (roomIds.length === 0) return reply.send({ siteId, circuitLimitWatts: 0, measuredDrawWatts: null, headroomWatts: null });
const rows = await db.select({ id: rowsTable.id }).from(rowsTable).where(inArray(rowsTable.roomId, roomIds));
const rowIds = rows.map((r) => r.id);
if (rowIds.length === 0) return reply.send({ siteId, circuitLimitWatts: 0, measuredDrawWatts: null, headroomWatts: null });
const racks = await db.select({ powerFeeds: racksTable.powerFeeds }).from(racksTable).where(inArray(racksTable.rowId, rowIds));
let circuitLimitWatts = 0;
for (const r of racks) {
const feeds = (r.powerFeeds as { circuitLimitWatts?: number }[] | null) ?? [];
for (const f of feeds) circuitLimitWatts += f.circuitLimitWatts ?? 0;
}
return reply.send({
siteId,
circuitLimitWatts,
measuredDrawWatts: null,
headroomWatts: null,
});
});
app.get("/gpu-inventory", async (req, reply) => {
const orgId = app.orgId(req);
const list = await db.select({
id: assetsTable.id,
assetId: assetsTable.assetId,
siteId: assetsTable.siteId,
status: assetsTable.status,
partNumber: assetsTable.partNumber,
}).from(assetsTable).where(and(eq(assetsTable.orgId, orgId), eq(assetsTable.category, "gpu")));
const bySite: Record<string, number> = {};
const byType: Record<string, number> = {};
for (const a of list) {
const sid = a.siteId ?? "unassigned";
bySite[sid] = (bySite[sid] ?? 0) + 1;
const typeKey = a.partNumber ?? "unknown";
byType[typeKey] = (byType[typeKey] ?? 0) + 1;
}
return reply.send({ total: list.length, bySite, byType });
});
}

View File

@@ -0,0 +1,77 @@
import type { FastifyInstance } from "fastify";
import { eq, and } from "drizzle-orm";
import { complianceProfiles as profilesTable } from "@sankofa/schema";
export async function complianceProfilesRoutes(app: FastifyInstance) {
const db = app.db;
app.get("/", async (req, reply) => {
const orgId = app.orgId(req);
const list = await db.select().from(profilesTable).where(eq(profilesTable.orgId, orgId));
return reply.send(list);
});
app.get<{ Params: { id: string } }>("/:id", async (req, reply) => {
const orgId = app.orgId(req);
const [row] = await db
.select()
.from(profilesTable)
.where(and(eq(profilesTable.id, req.params.id), eq(profilesTable.orgId, orgId)));
if (!row) return reply.notFound();
return reply.send(row);
});
app.post<{
Body: {
name: string;
firmwareFreezePolicy?: { lockedVersion?: string; minVersion?: string; maxVersion?: string };
allowedGenerations?: string[];
approvedSkus?: string[];
siteId?: string;
};
}>("/", async (req, reply) => {
const orgId = app.orgId(req);
const [inserted] = await db
.insert(profilesTable)
.values({
orgId,
name: req.body.name,
firmwareFreezePolicy: req.body.firmwareFreezePolicy ?? null,
allowedGenerations: req.body.allowedGenerations ?? null,
approvedSkus: req.body.approvedSkus ?? null,
siteId: req.body.siteId ?? null,
})
.returning();
return reply.code(201).send(inserted);
});
app.patch<{
Params: { id: string };
Body: Partial<{
name: string;
firmwareFreezePolicy: { lockedVersion?: string; minVersion?: string; maxVersion?: string };
allowedGenerations: string[];
approvedSkus: string[];
siteId: string;
}>;
}>("/:id", async (req, reply) => {
const orgId = app.orgId(req);
const [updated] = await db
.update(profilesTable)
.set({ ...req.body, updatedAt: new Date() })
.where(and(eq(profilesTable.id, req.params.id), eq(profilesTable.orgId, orgId)))
.returning();
if (!updated) return reply.notFound();
return reply.send(updated);
});
app.delete<{ Params: { id: string } }>("/:id", async (req, reply) => {
const orgId = app.orgId(req);
const [deleted] = await db
.delete(profilesTable)
.where(and(eq(profilesTable.id, req.params.id), eq(profilesTable.orgId, orgId)))
.returning({ id: profilesTable.id });
if (!deleted) return reply.notFound();
return reply.code(204).send();
});
}

View File

@@ -0,0 +1,44 @@
import type { FastifyInstance } from "fastify";
import { authRoutes } from "./auth";
import { vendorsRoutes } from "./vendors";
import { offersRoutes } from "./offers";
import { usersRoutes } from "./users";
import { rolesRoutes } from "./roles";
import { purchaseOrdersRoutes } from "./purchase-orders";
import { assetsRoutes } from "./assets";
import { sitesRoutes } from "./sites";
import { uploadRoutes } from "./upload";
import { workflowRoutes } from "./workflow";
import { inspectionRoutes } from "./inspection";
import { shipmentsRoutes } from "./shipments";
import { assetComponentsRoutes } from "./asset-components";
import { capacityRoutes } from "./capacity";
import { integrationsRoutes } from "./integrations";
import { maintenancesRoutes } from "./maintenances";
import { complianceProfilesRoutes } from "./compliance-profiles";
import { unifiControllersRoutes } from "./unifi-controllers";
import { reportsRoutes } from "./reports";
import { ingestionRoutes } from "./ingestion";
export async function registerV1Routes(app: FastifyInstance) {
await app.register(authRoutes, { prefix: "/auth" });
await app.register(vendorsRoutes, { prefix: "/vendors" });
await app.register(offersRoutes, { prefix: "/offers" });
await app.register(usersRoutes, { prefix: "/users" });
await app.register(rolesRoutes, { prefix: "/roles" });
await app.register(purchaseOrdersRoutes, { prefix: "/purchase-orders" });
await app.register(assetsRoutes, { prefix: "/assets" });
await app.register(sitesRoutes, { prefix: "/sites" });
await app.register(uploadRoutes, { prefix: "/upload" });
await app.register(workflowRoutes, { prefix: "/workflow" });
await app.register(inspectionRoutes, { prefix: "/inspection" });
await app.register(shipmentsRoutes, { prefix: "/shipments" });
await app.register(assetComponentsRoutes, { prefix: "/asset-components" });
await app.register(capacityRoutes, { prefix: "/capacity" });
await app.register(integrationsRoutes, { prefix: "/integrations" });
await app.register(maintenancesRoutes, { prefix: "/maintenances" });
await app.register(complianceProfilesRoutes, { prefix: "/compliance-profiles" });
await app.register(unifiControllersRoutes, { prefix: "/unifi-controllers" });
await app.register(reportsRoutes, { prefix: "/reports" });
await app.register(ingestionRoutes, { prefix: "/ingestion" });
}

View File

@@ -0,0 +1,35 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { buildApp } from "../../index.js";
describe("ingestion", () => {
let app: Awaited<ReturnType<typeof buildApp>>;
beforeAll(async () => {
app = await buildApp();
});
afterAll(async () => {
await app.close();
});
it("POST /api/v1/ingestion/offers returns 401 without x-ingestion-api-key", async () => {
const res = await app.inject({
method: "POST",
url: "/api/v1/ingestion/offers",
headers: { "content-type": "application/json" },
payload: { source: "email", quantity: 1, unit_price: "1" },
});
expect(res.statusCode).toBe(401);
expect(JSON.parse(res.payload).error).toContain("ingestion API key");
});
it("POST /api/v1/ingestion/offers returns 401 with wrong x-ingestion-api-key", async () => {
const res = await app.inject({
method: "POST",
url: "/api/v1/ingestion/offers",
headers: { "content-type": "application/json", "x-ingestion-api-key": "wrong" },
payload: { source: "email", quantity: 1, unit_price: "1" },
});
expect(res.statusCode).toBe(401);
});
});

View File

@@ -0,0 +1,60 @@
import type { FastifyInstance } from "fastify";
import { offers as offersTable } from "@sankofa/schema";
const INGESTION_KEY = process.env.INGESTION_API_KEY;
export async function ingestionRoutes(app: FastifyInstance) {
const db = app.db;
app.addHook("preHandler", async (req, reply) => {
const key = (req.headers["x-ingestion-api-key"] as string) || "";
if (!INGESTION_KEY || key !== INGESTION_KEY) {
return reply.code(401).send({ error: "Invalid or missing ingestion API key" });
}
});
app.post<{
Body: {
source: "scraped" | "email";
source_ref?: string;
source_metadata?: Record<string, unknown>;
vendor_id?: string | null;
sku?: string;
mpn?: string;
quantity: number;
unit_price: string;
incoterms?: string;
lead_time_days?: number;
country_of_origin?: string;
condition?: string;
warranty?: string;
evidence_refs?: { key: string; hash?: string }[];
};
}>("/offers", async (req, reply) => {
const orgId = (req.headers["x-org-id"] as string) || "default";
const body = req.body;
const now = new Date();
const [inserted] = await db
.insert(offersTable)
.values({
orgId,
vendorId: body.vendor_id ?? null,
sku: body.sku ?? null,
mpn: body.mpn ?? null,
quantity: body.quantity,
unitPrice: body.unit_price,
incoterms: body.incoterms ?? null,
leadTimeDays: body.lead_time_days ?? null,
countryOfOrigin: body.country_of_origin ?? null,
condition: body.condition ?? null,
warranty: body.warranty ?? null,
evidenceRefs: body.evidence_refs ?? null,
source: body.source,
sourceRef: body.source_ref ?? null,
sourceMetadata: body.source_metadata ?? null,
ingestedAt: now,
})
.returning();
return reply.code(201).send(inserted);
});
}

View File

@@ -0,0 +1,47 @@
import type { FastifyInstance } from "fastify";
import { eq, and } from "drizzle-orm";
import { inspectionTemplates as tplTable, inspectionRuns as runsTable } from "@sankofa/schema";
export async function inspectionRoutes(app: FastifyInstance) {
const db = app.db;
app.get("/templates", async (req, reply) => {
const orgId = app.orgId(req);
const list = await db.select().from(tplTable).where(eq(tplTable.orgId, orgId));
return reply.send(list);
});
app.post<{ Body: { category: string; name: string; steps: { id: string; label: string; required?: boolean }[] } }>("/templates", async (req, reply) => {
const orgId = app.orgId(req);
const [inserted] = await db.insert(tplTable).values({ orgId, category: req.body.category, name: req.body.name, steps: req.body.steps }).returning();
return reply.code(201).send(inserted);
});
app.get("/runs", async (req, reply) => {
const orgId = app.orgId(req);
const list = await db.select().from(runsTable).where(eq(runsTable.orgId, orgId));
return reply.send(list);
});
app.post<{ Body: { templateId: string; offerId?: string; assetId?: string } }>("/runs", async (req, reply) => {
const orgId = app.orgId(req);
const [inserted] = await db.insert(runsTable).values({
orgId,
templateId: req.body.templateId,
offerId: req.body.offerId ?? null,
assetId: req.body.assetId ?? null,
}).returning();
return reply.code(201).send(inserted);
});
app.patch<{ Params: { id: string }; Body: { status?: string; evidenceRefs?: { key: string; hash?: string }[]; resultNotes?: string } }>("/runs/:id", async (req, reply) => {
const orgId = app.orgId(req);
const [updated] = await db.update(runsTable).set({
...req.body,
completedAt: req.body.status === "pass" || req.body.status === "fail" ? new Date() : undefined,
updatedAt: new Date(),
}).where(and(eq(runsTable.id, req.params.id), eq(runsTable.orgId, orgId))).returning();
if (!updated) return reply.notFound();
return reply.send(updated);
});
}

View File

@@ -0,0 +1,50 @@
import type { FastifyInstance } from "fastify";
import { eq, and } from "drizzle-orm";
import { integrationMappings as mappingsTable, unifiProductCatalog as catalogTable } from "@sankofa/schema";
import { listUnifiDevices, enrichDevicesWithCatalog } from "../../integrations/unifi.js";
import { listProxmoxNodes } from "../../integrations/proxmox.js";
export async function integrationsRoutes(app: FastifyInstance) {
const db = app.db;
app.get<{ Params: { siteId: string } }>("/unifi/sites/:siteId/devices", async (req, reply) => {
const token = (req.headers["x-unifi-token"] as string) || "";
const baseUrl = (req.headers["x-unifi-url"] as string) || "";
const devices = await listUnifiDevices(baseUrl, token);
const catalog = await db.select().from(catalogTable);
const enriched = enrichDevicesWithCatalog(devices, catalog);
return reply.send(enriched);
});
app.get<{ Querystring: { generation?: string; approved_sovereign?: string } }>("/unifi/product-catalog", async (req, reply) => {
const gen = (req.query as { generation?: string }).generation;
const approved = (req.query as { approved_sovereign?: string }).approved_sovereign;
const conditions = [
...(gen ? [eq(catalogTable.generation, gen)] : []),
...(approved === "true" ? [eq(catalogTable.approvedSovereignDefault, true)] : []),
];
const list = conditions.length
? await db.select().from(catalogTable).where(and(...conditions))
: await db.select().from(catalogTable);
return reply.send(list);
});
app.get<{ Params: { sku: string } }>("/unifi/product-catalog/:sku", async (req, reply) => {
const [row] = await db.select().from(catalogTable).where(eq(catalogTable.sku, req.params.sku));
if (!row) return reply.notFound();
return reply.send(row);
});
app.get<{ Params: { siteId: string } }>("/proxmox/sites/:siteId/nodes", async (req, reply) => {
const token = (req.headers["x-proxmox-token"] as string) || "";
const baseUrl = (req.headers["x-proxmox-url"] as string) || "";
const nodes = await listProxmoxNodes(baseUrl, token);
return reply.send(nodes);
});
app.get("/mappings", async (req, reply) => {
const orgId = app.orgId(req);
const list = await db.select().from(mappingsTable).where(eq(mappingsTable.orgId, orgId));
return reply.send(list);
});
app.post<{ Body: { assetId?: string; siteId?: string; provider: string; externalId: string } }>("/mappings", async (req, reply) => {
const orgId = app.orgId(req);
const [inserted] = await db.insert(mappingsTable).values({ orgId, assetId: req.body.assetId ?? null, siteId: req.body.siteId ?? null, provider: req.body.provider, externalId: req.body.externalId }).returning();
return reply.code(201).send(inserted);
});
}

View File

@@ -0,0 +1,29 @@
import type { FastifyInstance } from "fastify";
import { eq, and } from "drizzle-orm";
import { maintenances as maintenancesTable } from "@sankofa/schema";
export async function maintenancesRoutes(app: FastifyInstance) {
const db = app.db;
app.get("/", async (req, reply) => {
const orgId = app.orgId(req);
const list = await db.select().from(maintenancesTable).where(eq(maintenancesTable.orgId, orgId));
return reply.send(list);
});
app.get<{ Params: { id: string } }>("/:id", async (req, reply) => {
const orgId = app.orgId(req);
const [row] = await db.select().from(maintenancesTable).where(and(eq(maintenancesTable.id, req.params.id), eq(maintenancesTable.orgId, orgId)));
if (!row) return reply.notFound();
return reply.send(row);
});
app.post<{ Body: { assetId: string; type: string; vendorTicketRef?: string; description?: string } }>("/", async (req, reply) => {
const orgId = app.orgId(req);
const [inserted] = await db.insert(maintenancesTable).values({ orgId, assetId: req.body.assetId, type: req.body.type, vendorTicketRef: req.body.vendorTicketRef ?? null, description: req.body.description ?? null }).returning();
return reply.code(201).send(inserted);
});
app.patch<{ Params: { id: string }; Body: { status?: string } }>("/:id", async (req, reply) => {
const orgId = app.orgId(req);
const [updated] = await db.update(maintenancesTable).set({ ...req.body, updatedAt: new Date() }).where(and(eq(maintenancesTable.id, req.params.id), eq(maintenancesTable.orgId, orgId))).returning();
if (!updated) return reply.notFound();
return reply.send(updated);
});
}

View File

@@ -0,0 +1,57 @@
import type { FastifyInstance } from "fastify";
import { eq, and, sql } from "drizzle-orm";
import { offers as offersTable } from "@sankofa/schema";
export async function offersRoutes(app: FastifyInstance) {
const db = app.db;
const listSchema = { querystring: { type: "object", properties: { limit: { type: "integer" }, offset: { type: "integer" } } } };
app.get("/", { schema: listSchema }, async (req, reply) => {
const orgId = app.orgId(req);
const vid = app.vendorId(req);
const limit = Math.min(Number((req.query as { limit?: number }).limit) || 50, 100);
const offset = Number((req.query as { offset?: number }).offset) || 0;
const conditions = [eq(offersTable.orgId, orgId)] as ReturnType<typeof eq>[];
if (vid) conditions.push(eq(offersTable.vendorId, vid));
const list = await db.select().from(offersTable).where(and(...conditions)).limit(limit).offset(offset);
const [{ total }] = await db.select({ total: sql<number>`count(*)::int` }).from(offersTable).where(and(...conditions));
return reply.send({ data: list, total });
});
app.get<{ Params: { id: string } }>("/:id", async (req, reply) => {
const orgId = app.orgId(req);
const vid = app.vendorId(req);
const conditions = [eq(offersTable.id, req.params.id), eq(offersTable.orgId, orgId)] as ReturnType<typeof eq>[];
if (vid) conditions.push(eq(offersTable.vendorId, vid));
const [row] = await db.select().from(offersTable).where(and(...conditions));
if (!row) return reply.notFound();
return reply.send(row);
});
app.post<{ Body: { vendorId?: string; sku?: string; mpn?: string; quantity: number; unitPrice: string; incoterms?: string; leadTimeDays?: number; countryOfOrigin?: string; condition?: string; warranty?: string; evidenceRefs?: { key: string; hash?: string }[] } }>("/", async (req, reply) => {
const orgId = app.orgId(req);
const vid = app.vendorId(req);
const vendorId = vid ?? req.body.vendorId ?? null;
if (!vendorId) throw app.httpErrors.badRequest("vendorId required (or login as vendor user)");
const [inserted] = await db.insert(offersTable).values({
orgId, vendorId, sku: req.body.sku ?? null, mpn: req.body.mpn ?? null, quantity: req.body.quantity, unitPrice: req.body.unitPrice,
incoterms: req.body.incoterms ?? null, leadTimeDays: req.body.leadTimeDays ?? null, countryOfOrigin: req.body.countryOfOrigin ?? null, condition: req.body.condition ?? null, warranty: req.body.warranty ?? null, evidenceRefs: req.body.evidenceRefs ?? null,
}).returning();
return reply.code(201).send(inserted);
});
app.patch<{ Params: { id: string }; Body: Partial<{ sku: string; mpn: string; quantity: number; unitPrice: string; status: string; evidenceRefs: { key: string; hash?: string }[] }> }>("/:id", async (req, reply) => {
const orgId = app.orgId(req);
const vid = app.vendorId(req);
const conditions = [eq(offersTable.id, req.params.id), eq(offersTable.orgId, orgId)] as ReturnType<typeof eq>[];
if (vid) conditions.push(eq(offersTable.vendorId, vid));
const [updated] = await db.update(offersTable).set({ ...req.body, updatedAt: new Date() }).where(and(...conditions)).returning();
if (!updated) return reply.notFound();
return reply.send(updated);
});
app.delete<{ Params: { id: string } }>("/:id", async (req, reply) => {
const orgId = app.orgId(req);
const vid = app.vendorId(req);
const conditions = [eq(offersTable.id, req.params.id), eq(offersTable.orgId, orgId)] as ReturnType<typeof eq>[];
if (vid) conditions.push(eq(offersTable.vendorId, vid));
const [deleted] = await db.delete(offersTable).where(and(...conditions)).returning({ id: offersTable.id });
if (!deleted) return reply.notFound();
return reply.code(204).send();
});
}

View File

@@ -0,0 +1,74 @@
import type { FastifyInstance } from "fastify";
import { eq, and } from "drizzle-orm";
import { purchaseOrders as poTable } from "@sankofa/schema";
export async function purchaseOrdersRoutes(app: FastifyInstance) {
const db = app.db;
app.get("/", async (req, reply) => {
const orgId = app.orgId(req);
const vid = app.vendorId(req);
const conditions = [eq(poTable.orgId, orgId)] as ReturnType<typeof eq>[];
if (vid) conditions.push(eq(poTable.vendorId, vid));
const list = await db.select().from(poTable).where(and(...conditions));
return reply.send(list);
});
app.get<{ Params: { id: string } }>("/:id", async (req, reply) => {
const orgId = app.orgId(req);
const vid = app.vendorId(req);
const conditions = [eq(poTable.id, req.params.id), eq(poTable.orgId, orgId)] as ReturnType<typeof eq>[];
if (vid) conditions.push(eq(poTable.vendorId, vid));
const [row] = await db.select().from(poTable).where(and(...conditions));
if (!row) return reply.notFound();
return reply.send(row);
});
app.post<{
Body: {
vendorId: string;
lineItems: { offerId?: string; sku?: string; quantity: number; unitPrice: string }[];
escrowTerms?: string;
inspectionSiteId?: string;
deliverySiteId?: string;
};
}>("/", async (req, reply) => {
const orgId = app.orgId(req);
const [inserted] = await db
.insert(poTable)
.values({
orgId,
vendorId: req.body.vendorId,
lineItems: req.body.lineItems,
escrowTerms: req.body.escrowTerms ?? null,
inspectionSiteId: req.body.inspectionSiteId ?? null,
deliverySiteId: req.body.deliverySiteId ?? null,
})
.returning();
return reply.code(201).send(inserted);
});
app.patch<{
Params: { id: string };
Body: Partial<{ status: string; approvalStage: string; escrowTerms: string }>;
}>("/:id", async (req, reply) => {
const orgId = app.orgId(req);
const [updated] = await db
.update(poTable)
.set({ ...req.body, updatedAt: new Date() })
.where(and(eq(poTable.id, req.params.id), eq(poTable.orgId, orgId)))
.returning();
if (!updated) return reply.notFound();
return reply.send(updated);
});
app.delete<{ Params: { id: string } }>("/:id", async (req, reply) => {
const orgId = app.orgId(req);
const [deleted] = await db
.delete(poTable)
.where(and(eq(poTable.id, req.params.id), eq(poTable.orgId, orgId)))
.returning({ id: poTable.id });
if (!deleted) return reply.notFound();
return reply.code(204).send();
});
}

View File

@@ -0,0 +1,46 @@
import type { FastifyInstance } from "fastify";
import { eq, and } from "drizzle-orm";
import { assets as assetsTable, integrationMappings as mappingsTable, unifiProductCatalog as catalogTable } from "@sankofa/schema";
export async function reportsRoutes(app: FastifyInstance) {
const db = app.db;
app.get<{ Querystring: { org_id?: string; site_id?: string } }>("/bom", async (req, reply) => {
const orgId = (req.query as { org_id?: string }).org_id ?? app.orgId(req);
const siteId = (req.query as { site_id?: string }).site_id;
const assetList = siteId
? await db.select().from(assetsTable).where(and(eq(assetsTable.orgId, orgId), eq(assetsTable.siteId, siteId)))
: await db.select().from(assetsTable).where(eq(assetsTable.orgId, orgId));
const mappings = await db.select().from(mappingsTable).where(eq(mappingsTable.orgId, orgId));
const catalog = await db.select().from(catalogTable);
const items = assetList.map((a) => {
const mapping = mappings.find((m) => m.assetId === a.id && m.provider === "unifi");
const catalogEntry = mapping ? catalog.find((c) => c.sku === mapping.externalId || c.modelName === mapping.externalId) : null;
return {
assetId: a.assetId,
category: a.category,
siteId: a.siteId,
catalogSku: catalogEntry?.sku,
generation: catalogEntry?.generation,
supportHorizon: catalogEntry?.supportHorizon,
};
});
return reply.send({ orgId, siteId: siteId ?? null, items });
});
app.get<{ Querystring: { org_id?: string; horizon_months?: string } }>("/support-risk", async (req, reply) => {
const orgId = (req.query as { org_id?: string }).org_id ?? app.orgId(req);
const horizonMonths = Math.min(24, Math.max(1, parseInt((req.query as { horizon_months?: string }).horizon_months ?? "12", 10) || 12));
const catalog = await db.select().from(catalogTable);
const mappings = await db.select().from(mappingsTable).where(and(eq(mappingsTable.orgId, orgId), eq(mappingsTable.provider, "unifi")));
const cutoff = new Date();
cutoff.setMonth(cutoff.getMonth() + horizonMonths);
const atRisk = catalog.filter((c) => {
if (!c.eolDate) return false;
const eol = new Date(c.eolDate);
return eol <= cutoff;
});
const bySku = atRisk.map((c) => ({ sku: c.sku, modelName: c.modelName, generation: c.generation, eolDate: c.eolDate, supportHorizon: c.supportHorizon }));
return reply.send({ orgId, horizonMonths, atRisk: bySku, deviceCount: mappings.length });
});
}

View File

@@ -0,0 +1,43 @@
import type { FastifyInstance } from "fastify";
import { eq } from "drizzle-orm";
import { roles as rolesTable } from "@sankofa/schema";
export async function rolesRoutes(app: FastifyInstance) {
const db = app.db;
app.get("/", async (_req, reply) => {
const list = await db.select().from(rolesTable);
return reply.send(list);
});
app.get<{ Params: { id: string } }>("/:id", async (req, reply) => {
const [row] = await db.select().from(rolesTable).where(eq(rolesTable.id, req.params.id));
if (!row) return reply.notFound();
return reply.send(row);
});
app.post<{ Body: { name: string; description?: string; permissions?: string[] } }>(
"/",
{ schema: { body: { type: "object", required: ["name"], properties: { name: { type: "string" }, description: { type: "string" }, permissions: { type: "array", items: { type: "string" } } } } } },
async (req, reply) => {
const [inserted] = await db.insert(rolesTable).values({
name: req.body.name,
description: req.body.description ?? null,
permissions: req.body.permissions ?? [],
}).returning();
return reply.code(201).send(inserted);
}
);
app.patch<{ Params: { id: string }; Body: Partial<{ name: string; description: string; permissions: string[] }> }>("/:id", async (req, reply) => {
const [updated] = await db.update(rolesTable).set({ ...req.body, updatedAt: new Date() }).where(eq(rolesTable.id, req.params.id)).returning();
if (!updated) return reply.notFound();
return reply.send(updated);
});
app.delete<{ Params: { id: string } }>("/:id", async (req, reply) => {
const [deleted] = await db.delete(rolesTable).where(eq(rolesTable.id, req.params.id)).returning({ id: rolesTable.id });
if (!deleted) return reply.notFound();
return reply.code(204).send();
});
}

View File

@@ -0,0 +1,39 @@
import type { FastifyInstance } from "fastify";
import { eq, and } from "drizzle-orm";
import { shipments as shipmentsTable, assets as assetsTable } from "@sankofa/schema";
export async function shipmentsRoutes(app: FastifyInstance) {
const db = app.db;
app.get("/", async (req, reply) => {
const orgId = app.orgId(req);
const list = await db.select().from(shipmentsTable).where(eq(shipmentsTable.orgId, orgId));
return reply.send(list);
});
app.get<{ Params: { id: string } }>("/:id", async (req, reply) => {
const orgId = app.orgId(req);
const [row] = await db.select().from(shipmentsTable).where(and(eq(shipmentsTable.id, req.params.id), eq(shipmentsTable.orgId, orgId)));
if (!row) return reply.notFound();
return reply.send(row);
});
app.post<{ Body: { purchaseOrderId: string; tracking?: string } }>("/", async (req, reply) => {
const orgId = app.orgId(req);
const [inserted] = await db.insert(shipmentsTable).values({ orgId, purchaseOrderId: req.body.purchaseOrderId, tracking: req.body.tracking ?? null }).returning();
return reply.code(201).send(inserted);
});
app.patch<{ Params: { id: string }; Body: { tracking?: string; status?: string } }>("/:id", async (req, reply) => {
const orgId = app.orgId(req);
const [updated] = await db.update(shipmentsTable).set({ ...req.body, updatedAt: new Date() }).where(and(eq(shipmentsTable.id, req.params.id), eq(shipmentsTable.orgId, orgId))).returning();
if (!updated) return reply.notFound();
return reply.send(updated);
});
app.post<{ Params: { id: string }; Body: { assetIds: string[] } }>("/:id/receive", async (req, reply) => {
const orgId = app.orgId(req);
const [shipment] = await db.select().from(shipmentsTable).where(and(eq(shipmentsTable.id, req.params.id), eq(shipmentsTable.orgId, orgId)));
if (!shipment) return reply.notFound();
for (const assetId of req.body.assetIds || []) {
await db.update(assetsTable).set({ status: "received", updatedAt: new Date() }).where(and(eq(assetsTable.id, assetId), eq(assetsTable.orgId, orgId)));
}
await db.update(shipmentsTable).set({ status: "received", updatedAt: new Date() }).where(and(eq(shipmentsTable.id, req.params.id), eq(shipmentsTable.orgId, orgId)));
return reply.send({ status: "received" });
});
}

View File

@@ -0,0 +1,68 @@
import type { FastifyInstance } from "fastify";
import { eq, and } from "drizzle-orm";
import { sites as sitesTable, racks as racksTable, positions as positionsTable, rows as rowsTable, rooms as roomsTable } from "@sankofa/schema";
export async function sitesRoutes(app: FastifyInstance) {
const db = app.db;
app.get("/", async (req, reply) => {
const orgId = app.orgId(req);
const list = await db.select().from(sitesTable).where(eq(sitesTable.orgId, orgId));
return reply.send(list);
});
app.get<{ Params: { id: string } }>("/:id", async (req, reply) => {
const orgId = app.orgId(req);
const [row] = await db.select().from(sitesTable).where(and(eq(sitesTable.id, req.params.id), eq(sitesTable.orgId, orgId)));
if (!row) return reply.notFound();
return reply.send(row);
});
app.post<{ Body: { name: string; regionId?: string; address?: string; networkMetadata?: unknown } }>("/", async (req, reply) => {
const orgId = app.orgId(req);
const [inserted] = await db.insert(sitesTable).values({
orgId, name: req.body.name, regionId: req.body.regionId ?? null, address: req.body.address ?? null, networkMetadata: req.body.networkMetadata ?? null,
}).returning();
return reply.code(201).send(inserted);
});
app.patch<{ Params: { id: string }; Body: Partial<{ name: string; address: string; networkMetadata: unknown }> }>("/:id", async (req, reply) => {
const orgId = app.orgId(req);
const [updated] = await db.update(sitesTable).set({
name: req.body.name,
address: req.body.address,
networkMetadata: req.body.networkMetadata as { uplinks?: string[]; vlans?: string[]; portProfiles?: string[]; ipRanges?: string[] } | undefined,
updatedAt: new Date(),
}).where(and(eq(sitesTable.id, req.params.id), eq(sitesTable.orgId, orgId))).returning();
if (!updated) return reply.notFound();
return reply.send(updated);
});
app.delete<{ Params: { id: string } }>("/:id", async (req, reply) => {
const orgId = app.orgId(req);
const [deleted] = await db.delete(sitesTable).where(and(eq(sitesTable.id, req.params.id), eq(sitesTable.orgId, orgId))).returning({ id: sitesTable.id });
if (!deleted) return reply.notFound();
return reply.code(204).send();
});
app.get<{ Params: { siteId: string } }>("/:siteId/racks", async (req, reply) => {
const orgId = app.orgId(req);
const [site] = await db.select().from(sitesTable).where(and(eq(sitesTable.id, req.params.siteId), eq(sitesTable.orgId, orgId)));
if (!site) return reply.notFound();
const roomsList = await db.select().from(roomsTable).where(eq(roomsTable.siteId, req.params.siteId));
const rowsList: { id: string }[] = [];
for (const r of roomsList) {
const rRows = await db.select({ id: rowsTable.id }).from(rowsTable).where(eq(rowsTable.roomId, r.id));
rowsList.push(...rRows);
}
const rackList: unknown[] = [];
for (const row of rowsList) {
const rRacks = await db.select().from(racksTable).where(eq(racksTable.rowId, row.id));
for (const rack of rRacks) {
const posList = await db.select().from(positionsTable).where(eq(positionsTable.rackId, rack.id));
rackList.push({ ...rack, positions: posList });
}
}
return reply.send(rackList);
});
}

View File

@@ -0,0 +1,59 @@
import type { FastifyInstance } from "fastify";
import { eq, and } from "drizzle-orm";
import { unifiControllers as controllersTable } from "@sankofa/schema";
export async function unifiControllersRoutes(app: FastifyInstance) {
const db = app.db;
app.get("/", async (req, reply) => {
const orgId = app.orgId(req);
const list = await db.select().from(controllersTable).where(eq(controllersTable.orgId, orgId));
return reply.send(list);
});
app.get<{ Params: { id: string } }>("/:id", async (req, reply) => {
const orgId = app.orgId(req);
const [row] = await db
.select()
.from(controllersTable)
.where(and(eq(controllersTable.id, req.params.id), eq(controllersTable.orgId, orgId)));
if (!row) return reply.notFound();
return reply.send(row);
});
app.post<{ Body: { siteId?: string; baseUrl: string; role: string; region?: string } }>("/", async (req, reply) => {
const orgId = app.orgId(req);
const [inserted] = await db
.insert(controllersTable)
.values({
orgId,
siteId: req.body.siteId ?? null,
baseUrl: req.body.baseUrl,
role: req.body.role,
region: req.body.region ?? null,
})
.returning();
return reply.code(201).send(inserted);
});
app.patch<{ Params: { id: string }; Body: Partial<{ baseUrl: string; role: string; region: string }> }>("/:id", async (req, reply) => {
const orgId = app.orgId(req);
const [updated] = await db
.update(controllersTable)
.set({ ...req.body, updatedAt: new Date() })
.where(and(eq(controllersTable.id, req.params.id), eq(controllersTable.orgId, orgId)))
.returning();
if (!updated) return reply.notFound();
return reply.send(updated);
});
app.delete<{ Params: { id: string } }>("/:id", async (req, reply) => {
const orgId = app.orgId(req);
const [deleted] = await db
.delete(controllersTable)
.where(and(eq(controllersTable.id, req.params.id), eq(controllersTable.orgId, orgId)))
.returning({ id: controllersTable.id });
if (!deleted) return reply.notFound();
return reply.code(204).send();
});
}

View File

@@ -0,0 +1,18 @@
import type { FastifyInstance } from "fastify";
import { uploadDocument } from "../../storage";
import { randomUUID } from "crypto";
export async function uploadRoutes(app: FastifyInstance) {
app.post<{ Querystring: { prefix?: string } }>("/", async (req, reply) => {
const data = await req.file();
if (!data) return reply.badRequest("No file");
const buf = await data.toBuffer();
const prefix = (req.query as { prefix?: string }).prefix ?? "documents";
const key = `${prefix}/${randomUUID()}/${data.filename}`;
const result = await uploadDocument(key, buf, data.mimetype || "application/octet-stream", {
originalName: data.filename,
orgId: app.orgId(req),
});
return reply.send({ key: result.key, bucket: result.bucket, etag: result.etag });
});
}

View File

@@ -0,0 +1,77 @@
import type { FastifyInstance } from "fastify";
import { eq, and, sql } from "drizzle-orm";
import { users as usersTable, userRoles, roles as rolesTable } from "@sankofa/schema";
export async function usersRoutes(app: FastifyInstance) {
const db = app.db;
const listSchema = { querystring: { type: "object", properties: { limit: { type: "integer" }, offset: { type: "integer" } } } };
app.get("/", { schema: listSchema }, async (req, reply) => {
const orgId = app.orgId(req);
const limit = Math.min(Number((req.query as { limit?: number }).limit) || 50, 100);
const offset = Number((req.query as { offset?: number }).offset) || 0;
const list = await db.select().from(usersTable).where(eq(usersTable.orgId, orgId)).limit(limit).offset(offset);
const [{ total }] = await db.select({ total: sql<number>`count(*)::int` }).from(usersTable).where(eq(usersTable.orgId, orgId));
return reply.send({ data: list, total });
});
app.get<{ Params: { id: string } }>("/:id", async (req, reply) => {
const orgId = app.orgId(req);
const [row] = await db.select().from(usersTable).where(and(eq(usersTable.id, req.params.id), eq(usersTable.orgId, orgId)));
if (!row) return reply.notFound();
const ur = await db.select({ roleId: userRoles.roleId, roleName: rolesTable.name }).from(userRoles).innerJoin(rolesTable, eq(userRoles.roleId, rolesTable.id)).where(eq(userRoles.userId, row.id));
return reply.send({ ...row, roleIds: ur.map((r) => r.roleId), roleNames: ur.map((r) => r.roleName) });
});
app.post<{
Body: { email: string; name?: string; orgUnitId?: string; vendorId?: string };
}>(
"/",
{
schema: {
body: { type: "object", required: ["email"], properties: { email: { type: "string" }, name: { type: "string" }, orgUnitId: { type: "string" }, vendorId: { type: "string" } } },
},
},
async (req, reply) => {
const orgId = app.orgId(req);
const [inserted] = await db.insert(usersTable).values({
orgId,
email: req.body.email,
name: req.body.name ?? null,
orgUnitId: req.body.orgUnitId ?? null,
vendorId: req.body.vendorId ?? null,
}).returning();
return reply.code(201).send(inserted);
}
);
app.patch<{ Params: { id: string }; Body: Partial<{ name: string; orgUnitId: string; vendorId: string }> }>("/:id", async (req, reply) => {
const orgId = app.orgId(req);
const [updated] = await db.update(usersTable).set({ ...req.body, updatedAt: new Date() }).where(and(eq(usersTable.id, req.params.id), eq(usersTable.orgId, orgId))).returning();
if (!updated) return reply.notFound();
return reply.send(updated);
});
app.delete<{ Params: { id: string } }>("/:id", async (req, reply) => {
const orgId = app.orgId(req);
const [deleted] = await db.delete(usersTable).where(and(eq(usersTable.id, req.params.id), eq(usersTable.orgId, orgId))).returning({ id: usersTable.id });
if (!deleted) return reply.notFound();
return reply.code(204).send();
});
app.post<{ Params: { id: string }; Body: { roleId: string } }>("/:id/roles", async (req, reply) => {
const orgId = app.orgId(req);
const [user] = await db.select().from(usersTable).where(and(eq(usersTable.id, req.params.id), eq(usersTable.orgId, orgId)));
if (!user) return reply.notFound();
await db.insert(userRoles).values({ userId: user.id, roleId: req.body.roleId }).onConflictDoNothing();
return reply.code(204).send();
});
app.delete<{ Params: { id: string }; Querystring: { roleId: string } }>("/:id/roles", async (req, reply) => {
const orgId = app.orgId(req);
const [user] = await db.select().from(usersTable).where(and(eq(usersTable.id, req.params.id), eq(usersTable.orgId, orgId)));
if (!user) return reply.notFound();
await db.delete(userRoles).where(and(eq(userRoles.userId, user.id), eq(userRoles.roleId, (req.query as { roleId: string }).roleId)));
return reply.code(204).send();
});
}

View File

@@ -0,0 +1,26 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { buildApp } from "../../index.js";
describe("vendors", () => {
let app: Awaited<ReturnType<typeof buildApp>>;
beforeAll(async () => {
app = await buildApp();
});
afterAll(async () => {
await app.close();
});
it("GET /api/v1/vendors without auth returns 401 or 500 when DB unavailable", async () => {
const res = await app.inject({
method: "GET",
url: "/api/v1/vendors",
headers: { "x-org-id": "default" },
});
expect([401, 500]).toContain(res.statusCode);
const body = JSON.parse(res.payload);
expect(body.error).toBeDefined();
if (res.statusCode === 401) expect(body.code).toBe("UNAUTHORIZED");
});
});

View File

@@ -0,0 +1,58 @@
import type { FastifyInstance } from "fastify";
import { eq, and, sql } from "drizzle-orm";
import { vendors as vendorsTable } from "@sankofa/schema";
import { getActorFromRequest, writeAudit } from "../../audit.js";
export async function vendorsRoutes(app: FastifyInstance) {
const db = app.db;
app.get("/", {
config: { permission: "vendors:read" },
schema: { querystring: { type: "object", properties: { limit: { type: "integer" }, offset: { type: "integer" } } } },
}, async (req, reply) => {
const orgId = app.orgId(req);
const vid = app.vendorId(req);
const limit = Math.min(Number((req.query as { limit?: number }).limit) || 50, 100);
const offset = Number((req.query as { offset?: number }).offset) || 0;
if (vid) {
const [row] = await db.select().from(vendorsTable).where(and(eq(vendorsTable.id, vid), eq(vendorsTable.orgId, orgId)));
return reply.send({ data: row ? [row] : [], total: row ? 1 : 0 });
}
const list = await db.select().from(vendorsTable).where(eq(vendorsTable.orgId, orgId)).limit(limit).offset(offset);
const [{ total }] = await db.select({ total: sql<number>`count(*)::int` }).from(vendorsTable).where(eq(vendorsTable.orgId, orgId));
return reply.send({ data: list, total });
});
app.get<{ Params: { id: string } }>("/:id", { config: { permission: "vendors:read" } }, async (req, reply) => {
const orgId = app.orgId(req);
const vid = app.vendorId(req);
if (vid && req.params.id !== vid) throw app.httpErrors.forbidden("Vendor users may only access their own vendor");
const [row] = await db.select().from(vendorsTable).where(and(eq(vendorsTable.id, req.params.id), eq(vendorsTable.orgId, orgId)));
if (!row) return reply.notFound();
return reply.send(row);
});
app.post<{ Body: { legalName: string; contacts?: unknown; trustTier?: string } }>("/", {
config: { permission: "vendors:write" },
schema: { body: { type: "object", required: ["legalName"], properties: { legalName: { type: "string" }, contacts: {}, trustTier: { type: "string" } } } },
}, async (req, reply) => {
if (app.vendorId(req)) throw app.httpErrors.forbidden("Vendor users cannot create vendors");
const orgId = app.orgId(req);
const [inserted] = await db.insert(vendorsTable).values({ orgId, legalName: req.body.legalName, contacts: (req.body.contacts as { email?: string; phone?: string; name?: string }[] | null) ?? null, trustTier: req.body.trustTier ?? "unknown" }).returning();
return reply.code(201).send(inserted);
});
app.patch<{ Params: { id: string }; Body: Record<string, unknown> }>("/:id", { config: { permission: "vendors:write" } }, async (req, reply) => {
if (app.vendorId(req)) throw app.httpErrors.forbidden("Vendor users cannot update vendors");
const orgId = app.orgId(req);
const [before] = await db.select().from(vendorsTable).where(and(eq(vendorsTable.id, req.params.id), eq(vendorsTable.orgId, orgId)));
const [updated] = await db.update(vendorsTable).set({ ...req.body, updatedAt: new Date() }).where(and(eq(vendorsTable.id, req.params.id), eq(vendorsTable.orgId, orgId))).returning();
if (!updated) return reply.notFound();
const actor = getActorFromRequest(req);
await writeAudit(db, { orgId, ...actor, action: "vendor.update", resourceType: "vendor", resourceId: req.params.id, beforeState: before ? { ...before } : undefined, afterState: { ...updated } });
return reply.send(updated);
});
app.delete<{ Params: { id: string } }>("/:id", { config: { permission: "vendors:write" } }, async (req, reply) => {
if (app.vendorId(req)) throw app.httpErrors.forbidden("Vendor users cannot delete vendors");
const orgId = app.orgId(req);
const [deleted] = await db.delete(vendorsTable).where(and(eq(vendorsTable.id, req.params.id), eq(vendorsTable.orgId, orgId))).returning({ id: vendorsTable.id });
if (!deleted) return reply.notFound();
return reply.code(204).send();
});
}

View File

@@ -0,0 +1,56 @@
import type { FastifyInstance } from "fastify";
import { eq, and } from "drizzle-orm";
import { purchaseOrders as poTable, offers as offersTable, vendors as vendorsTable } from "@sankofa/schema";
import { nextPOStage, canTransitionPO, computeOfferRiskScore } from "@sankofa/workflow";
export async function workflowRoutes(app: FastifyInstance) {
const db = app.db;
app.post<{ Params: { id: string }; Body: { trustTier?: string; priceDeviation?: number; conditionAmbiguity?: boolean } }>("/offers/:id/risk-score", async (req, reply) => {
const orgId = app.orgId(req);
const [offer] = await db.select().from(offersTable).where(and(eq(offersTable.id, req.params.id), eq(offersTable.orgId, orgId)));
if (!offer) return reply.notFound();
const [vendor] = offer.vendorId ? await db.select().from(vendorsTable).where(eq(vendorsTable.id, offer.vendorId)) : [null];
const factors = { trustTier: req.body.trustTier ?? vendor?.trustTier ?? "unknown", priceDeviation: req.body.priceDeviation, conditionAmbiguity: req.body.conditionAmbiguity ?? !offer.condition };
const { score, factors: outFactors } = computeOfferRiskScore(factors);
await db.update(offersTable).set({ riskScore: String(score), riskFactors: outFactors as unknown as Record<string, unknown>, updatedAt: new Date() }).where(and(eq(offersTable.id, req.params.id), eq(offersTable.orgId, orgId)));
return reply.send({ score, factors: outFactors });
});
app.post<{ Params: { id: string } }>("/purchase-orders/:id/submit", async (req, reply) => {
const orgId = app.orgId(req);
const [po] = await db.select().from(poTable).where(and(eq(poTable.id, req.params.id), eq(poTable.orgId, orgId)));
if (!po) return reply.notFound();
if (po.status !== "draft") return reply.badRequest("PO not in draft");
await db.update(poTable).set({ status: "pending_approval", approvalStage: "requester", updatedAt: new Date() }).where(and(eq(poTable.id, req.params.id), eq(poTable.orgId, orgId)));
return reply.send({ status: "pending_approval", approvalStage: "requester" });
});
app.post<{ Params: { id: string } }>("/purchase-orders/:id/approve", async (req, reply) => {
const orgId = app.orgId(req);
const [po] = await db.select().from(poTable).where(and(eq(poTable.id, req.params.id), eq(poTable.orgId, orgId)));
if (!po) return reply.notFound();
if (po.status !== "pending_approval") return reply.badRequest("PO not pending approval");
const next = nextPOStage(po.approvalStage as "requester" | "procurement" | "finance" | "executive" | null);
if (next) {
await db.update(poTable).set({ approvalStage: next, updatedAt: new Date() }).where(and(eq(poTable.id, req.params.id), eq(poTable.orgId, orgId)));
return reply.send({ status: "pending_approval", approvalStage: next });
}
await db.update(poTable).set({ status: "approved", approvalStage: "executive", updatedAt: new Date() }).where(and(eq(poTable.id, req.params.id), eq(poTable.orgId, orgId)));
return reply.send({ status: "approved" });
});
app.post<{ Params: { id: string } }>("/purchase-orders/:id/reject", async (req, reply) => {
const orgId = app.orgId(req);
const [po] = await db.select().from(poTable).where(and(eq(poTable.id, req.params.id), eq(poTable.orgId, orgId)));
if (!po) return reply.notFound();
if (po.status !== "pending_approval") return reply.badRequest("PO not pending approval");
await db.update(poTable).set({ status: "rejected", updatedAt: new Date() }).where(and(eq(poTable.id, req.params.id), eq(poTable.orgId, orgId)));
return reply.send({ status: "rejected" });
});
app.patch<{ Params: { id: string }; Body: { status: string } }>("/purchase-orders/:id/status", async (req, reply) => {
const orgId = app.orgId(req);
const [po] = await db.select().from(poTable).where(and(eq(poTable.id, req.params.id), eq(poTable.orgId, orgId)));
if (!po) return reply.notFound();
const to = req.body.status as "draft" | "pending_approval" | "approved" | "rejected" | "ordered" | "received";
if (!canTransitionPO(po.status as "draft" | "pending_approval" | "approved" | "rejected" | "ordered" | "received", to)) return reply.badRequest("Invalid status transition");
await db.update(poTable).set({ status: to, updatedAt: new Date() }).where(and(eq(poTable.id, req.params.id), eq(poTable.orgId, orgId)));
return reply.send({ status: to });
});
}

View File

@@ -0,0 +1,14 @@
/** Standard error payload for API responses */
export interface ApiErrorPayload {
error: string;
code?: string;
details?: unknown;
}
export const errorCodes = {
BAD_REQUEST: "BAD_REQUEST",
UNAUTHORIZED: "UNAUTHORIZED",
FORBIDDEN: "FORBIDDEN",
NOT_FOUND: "NOT_FOUND",
CONFLICT: "CONFLICT",
} as const;

67
apps/api/src/storage.ts Normal file
View File

@@ -0,0 +1,67 @@
import {
S3Client,
PutObjectCommand,
GetObjectCommand,
HeadObjectCommand,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const endpoint = process.env.S3_ENDPOINT;
const region = process.env.S3_REGION || "us-east-1";
const bucket = process.env.S3_BUCKET || "sankofa-documents";
const forcePathStyle = Boolean(endpoint);
export const s3Client = new S3Client({
region,
...(endpoint
? {
endpoint,
forcePathStyle,
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY || "minioadmin",
secretAccessKey: process.env.S3_SECRET_KEY || "minioadmin",
},
}
: {}),
});
export interface UploadResult {
key: string;
bucket: string;
etag?: string;
}
export async function uploadDocument(
key: string,
body: Buffer | Uint8Array,
contentType: string,
metadata?: Record<string, string>
): Promise<UploadResult> {
const command = new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: body,
ContentType: contentType,
Metadata: metadata,
});
const out = await s3Client.send(command);
return { key, bucket, etag: out.ETag };
}
export async function getDocumentKey(key: string): Promise<boolean> {
try {
await s3Client.send(
new HeadObjectCommand({ Bucket: bucket, Key: key })
);
return true;
} catch {
return false;
}
}
export async function getSignedDownloadUrl(key: string, expiresIn = 3600): Promise<string> {
const command = new GetObjectCommand({ Bucket: bucket, Key: key });
return getSignedUrl(s3Client, command, { expiresIn });
}
export { bucket as defaultBucket };

13
apps/api/tsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"outDir": "dist",
"rootDir": "src",
"skipLibCheck": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}