Initial commit: add .gitignore and README
Some checks failed
CI / lint-and-test (push) Has been cancelled
Some checks failed
CI / lint-and-test (push) Has been cancelled
This commit is contained in:
36
apps/api/package.json
Normal file
36
apps/api/package.json
Normal 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
31
apps/api/src/audit.ts
Normal 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
70
apps/api/src/auth.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
7
apps/api/src/health.test.ts
Normal file
7
apps/api/src/health.test.ts
Normal 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
69
apps/api/src/index.ts
Normal 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); });
|
||||
2
apps/api/src/integrations/proxmox.ts
Normal file
2
apps/api/src/integrations/proxmox.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export interface ProxmoxNode { node: string; status?: string; }
|
||||
export async function listProxmoxNodes(_baseUrl: string, _token: string): Promise<ProxmoxNode[]> { return []; }
|
||||
2
apps/api/src/integrations/redfish.ts
Normal file
2
apps/api/src/integrations/redfish.ts
Normal 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; }
|
||||
26
apps/api/src/integrations/unifi.ts
Normal file
26
apps/api/src/integrations/unifi.ts
Normal 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 };
|
||||
});
|
||||
}
|
||||
20
apps/api/src/openapi-spec.ts
Normal file
20
apps/api/src/openapi-spec.ts
Normal 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();
|
||||
30
apps/api/src/routes/v1/asset-components.ts
Normal file
30
apps/api/src/routes/v1/asset-components.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
90
apps/api/src/routes/v1/assets.ts
Normal file
90
apps/api/src/routes/v1/assets.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
25
apps/api/src/routes/v1/auth.test.ts
Normal file
25
apps/api/src/routes/v1/auth.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
42
apps/api/src/routes/v1/auth.ts
Normal file
42
apps/api/src/routes/v1/auth.ts
Normal 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 } });
|
||||
}
|
||||
);
|
||||
}
|
||||
89
apps/api/src/routes/v1/capacity.ts
Normal file
89
apps/api/src/routes/v1/capacity.ts
Normal 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 });
|
||||
});
|
||||
}
|
||||
77
apps/api/src/routes/v1/compliance-profiles.ts
Normal file
77
apps/api/src/routes/v1/compliance-profiles.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
44
apps/api/src/routes/v1/index.ts
Normal file
44
apps/api/src/routes/v1/index.ts
Normal 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" });
|
||||
}
|
||||
35
apps/api/src/routes/v1/ingestion.test.ts
Normal file
35
apps/api/src/routes/v1/ingestion.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
60
apps/api/src/routes/v1/ingestion.ts
Normal file
60
apps/api/src/routes/v1/ingestion.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
47
apps/api/src/routes/v1/inspection.ts
Normal file
47
apps/api/src/routes/v1/inspection.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
50
apps/api/src/routes/v1/integrations.ts
Normal file
50
apps/api/src/routes/v1/integrations.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
29
apps/api/src/routes/v1/maintenances.ts
Normal file
29
apps/api/src/routes/v1/maintenances.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
57
apps/api/src/routes/v1/offers.ts
Normal file
57
apps/api/src/routes/v1/offers.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
74
apps/api/src/routes/v1/purchase-orders.ts
Normal file
74
apps/api/src/routes/v1/purchase-orders.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
46
apps/api/src/routes/v1/reports.ts
Normal file
46
apps/api/src/routes/v1/reports.ts
Normal 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 });
|
||||
});
|
||||
}
|
||||
43
apps/api/src/routes/v1/roles.ts
Normal file
43
apps/api/src/routes/v1/roles.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
39
apps/api/src/routes/v1/shipments.ts
Normal file
39
apps/api/src/routes/v1/shipments.ts
Normal 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" });
|
||||
});
|
||||
}
|
||||
68
apps/api/src/routes/v1/sites.ts
Normal file
68
apps/api/src/routes/v1/sites.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
59
apps/api/src/routes/v1/unifi-controllers.ts
Normal file
59
apps/api/src/routes/v1/unifi-controllers.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
18
apps/api/src/routes/v1/upload.ts
Normal file
18
apps/api/src/routes/v1/upload.ts
Normal 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 });
|
||||
});
|
||||
}
|
||||
77
apps/api/src/routes/v1/users.ts
Normal file
77
apps/api/src/routes/v1/users.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
26
apps/api/src/routes/v1/vendors.test.ts
Normal file
26
apps/api/src/routes/v1/vendors.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
58
apps/api/src/routes/v1/vendors.ts
Normal file
58
apps/api/src/routes/v1/vendors.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
56
apps/api/src/routes/v1/workflow.ts
Normal file
56
apps/api/src/routes/v1/workflow.ts
Normal 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 });
|
||||
});
|
||||
}
|
||||
14
apps/api/src/schemas/errors.ts
Normal file
14
apps/api/src/schemas/errors.ts
Normal 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
67
apps/api/src/storage.ts
Normal 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
13
apps/api/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user