import type { FastifyInstance } from "fastify"; import { z } from "zod"; import type { Db } from "./db.js"; import type { AppConfig } from "./config.js"; import { getProductBySlug } from "./domain/product.js"; import type { BaseAdapter } from "./integration/baseAdapter.js"; import { ExportError, exportState, grantExportConsent, revokeExportConsent, createExportBatch, sendExportNow, getExportItem, retryExportItem, grantConsentSchema, revokeConsentSchema, dispatchSchema, retryItemSchema, } from "./domain/exportOutbox.js"; import { ContractError } from "./domain/baseContract.js"; /** * Outbox eksportu do Base. Odczyt dla zalogowanych; zgoda, dispatch, * odwołanie i retry wymagają roli approver/admin — są to decyzje * o zewnętrznym zapisie, nie operacje przygotowawcze. */ export function registerExportRoutes(app: FastifyInstance, db: Db, cfg: AppConfig, adapter: BaseAdapter): void { app.register(async (scope) => { scope.addHook("preHandler", async (req, reply) => { reply.header("Cache-Control", "private, no-store"); if (!req.user) return reply.code(401).send({ error: "Wymagane zalogowanie." }); if (req.method !== "GET") { const origin = req.headers.origin, site = req.headers["sec-fetch-site"]; let matchingOrigin = !origin; if (origin) { try { matchingOrigin = new URL(origin).host === req.headers.host; } catch { matchingOrigin = false; } } if (site === "cross-site" || (site !== "same-origin" && !matchingOrigin)) return reply.code(403).send({ error: "Niedozwolone źródło żądania." }); } }); scope.setErrorHandler((err, _req, reply) => { if (err instanceof z.ZodError) return reply.code(400).send({ error: "Nieprawidłowy zakres eksportu. Sprawdź wymagane pola." }); if (err instanceof ExportError) return reply.code(err.statusCode).send({ error: err.message }); if (err instanceof ContractError) return reply.code(409).send({ error: err.message }); return reply.code(500).send({ error: "Nie udało się obsłużyć eksportu. Stan outboxu jest trwały — sprawdź historię przed ponowieniem." }); }); const productId = (slug: string) => { const product = getProductBySlug(db, slug); if (!product) throw new ExportError("Nie znaleziono produktu.", 404); return product.id; }; const requireApprover = (req: { user: { role: string } | null }) => { if (!["approver", "admin"].includes(req.user!.role)) throw new ExportError("Zgody i zapis do Base wymagają roli approver/admin.", 403); }; const base = "/api/products/:slug/export"; scope.get(base, async (req) => exportState(db, productId((req.params as { slug: string }).slug), adapter)); // Bezpośrednia wysyłka — jeden przycisk: snapshot, akceptacja, zgoda i wsad // powstają w locie; dispatch rusza natychmiast przez kolejkę zadań. // Kliknięcie jest decyzją — aktor trafia do audytu, rola nie blokuje. scope.post(`${base}/send-now`, { bodyLimit: 8192 }, async (req) => sendExportNow(db, cfg, { productId: productId((req.params as { slug: string }).slug), actorId: req.user!.id }) ); scope.get(`${base}/items/:id`, async (req) => { const params = req.params as { slug: string; id: string }; return getExportItem(db, productId(params.slug), params.id); }); scope.post(`${base}/consents`, { bodyLimit: 8192 }, async (req) => { requireApprover(req); const body = grantConsentSchema.parse(req.body); const consent = grantExportConsent(db, cfg, { ...body, productId: productId((req.params as { slug: string }).slug), actorId: req.user!.id }); return { consent }; }); scope.post(`${base}/consents/:id/revoke`, { bodyLimit: 8192 }, async (req) => { requireApprover(req); const params = req.params as { slug: string; id: string }; const body = revokeConsentSchema.parse(req.body); const consent = revokeExportConsent(db, { ...body, productId: productId(params.slug), consentId: params.id, actorId: req.user!.id }); return { consent }; }); scope.post(`${base}/dispatch`, { bodyLimit: 8192 }, async (req) => { requireApprover(req); const body = dispatchSchema.parse(req.body); return createExportBatch(db, cfg, { ...body, productId: productId((req.params as { slug: string }).slug), actorId: req.user!.id }); }); scope.post(`${base}/items/:id/retry`, { bodyLimit: 8192 }, async (req) => { requireApprover(req); const params = req.params as { slug: string; id: string }; const body = retryItemSchema.parse(req.body); return { item: retryExportItem(db, { ...body, productId: productId(params.slug), itemId: params.id, actorId: req.user!.id }) }; }); }); }