import fs from "node:fs"; import path from "node:path"; import crypto from "node:crypto"; import sharp from "sharp"; import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; import { z } from "zod"; import { registerOrchestratorRoutes } from "./orchestratorRoutes.js"; import { registerExportRoutes } from "./exportRoutes.js"; import { registerSettingsRoutes } from "./settingsRoutes.js"; import { disabledBaseAdapter, type BaseAdapter } from "./integration/baseAdapter.js"; import type { Db } from "./db.js"; import { tx } from "./db.js"; import type { AppConfig } from "./config.js"; import { login, logout, resolveSession, countUsers } from "./auth.js"; import { getProductBySlug, listProducts, createProduct, deleteProduct, ProductDeleteError } from "./domain/product.js"; import { getFacts, setFact, getFact } from "./domain/facts.js"; import { listVariants, listShades, ensureVariantMatrix, pruneVariantSides, deleteDraftVariant, VariantDeleteError, setFabricVerification, setFabricAiNote, fabricStructureClause, FABRIC_STRUCTURES, setShadeVerification, setAllShadesVerification, setShadePhoto } from "./domain/fabric.js"; import { listCatalogFabrics, getCatalogFabric, catalogDetail, catalogShades, catalogCardSource, catalogMedia } from "./domain/fabricCatalog.js"; import { enqueueSourceSchema, enqueueSourceImport, getSourceImport, listSourceImports, sourceImportDto, readImportSnapshot, cancelSourceImport, SourceImportError } from "./domain/sourceImports.js"; import { enqueueProductImportSchema, enqueueProductImport, enqueueProductReimport, fetchVendorVariantImages, refetchProductImages, ProductImportError } from "./domain/productImport.js"; import { enqueueFabricImportSchema, enqueueFabricImport, FabricImportError } from "./domain/fabricImport.js"; import { listFurnitureLegs, getFurnitureLeg, upsertFurnitureLeg, legIdFromName } from "./domain/furnitureLegs.js"; import { addEans, backfillVariantEans } from "./domain/eanPool.js"; import { importLegFromUrl, LegImportError } from "./domain/legImport.js"; import { SourceFetchError } from "./media/sourceFetch.js"; import { listSources, ingestUpload, IngestError } from "./media/ingest.js"; import { listAssets, decide, decideBatch, approvedAssetForRole, registerAsset, restoreCandidate } from "./domain/assets.js"; import { reviewAsset, latestVerdictsForProduct } from "./domain/macius.js"; import { groqConfigured } from "./ai/groq.js"; import { translateFabricNote } from "./ai/fabricNote.js"; import { tuneShotPrompt, ShotOverrides, ChatTurn, type ShotContext, type ShotOverridesT } from "./ai/shotTuner.js"; import { shotGenContext, shotRecipePrompt, shotRefSlots, orderedShotRefIds, resolveFabricRefs } from "./worker.js"; import { applyShotOverrides, materialSwapPrompt, roomScenePrompt } from "./ai/recipes.js"; import { STUDIO_ENGINE, STUDIO_ROLES, METHOD_LABEL, planStudioShot, enqueueStudioShot, setExclusion, setMaterialLink, materialBaseProduct, StudioError } from "./domain/studio.js"; import { pythonQueueState } from "./pyRunner.js"; import { listJobs, getJob, enqueueJob, type JobRow } from "./domain/jobs.js"; import { audit } from "./domain/audit.js"; import { confirmedDims, buildBlueprintSvg, planBlueprintViews, resolveViewRefs, blueprintPromptCtx, buildBlueprintHybridPrompt, buildBlueprintAiPrompt, resolveBlueprintAiRefs, type BlueprintView, } from "./domain/blueprint.js"; import { buildProductCard } from "./domain/productCard.js"; import { buildRecolor, recolorReadiness, maskOverlayPng, confirmMasterMask } from "./domain/recolor.js"; import { buildMediaReview, buildReviewContactSheet, decideMediaReview, ReviewConflict, OFFER_ROLES } from "./domain/mediaReview.js"; import { buildOfferContent, listContentRevisions, getContentRevision, saveContentRevision, decideContentRevision, saveContentSchema, contentDecisionSchema, ContentConflict } from "./domain/offerContent.js"; import { productProfile } from "./domain/productProfile.js"; import { buildOfferPackage, listOfferSnapshots, getOfferSnapshot, saveOfferSnapshot, decideOfferSnapshot, saveSnapshotSchema, snapshotDecisionSchema, PackageConflict } from "./domain/offerPackage.js"; import { buildBrwWorkbook, previewBrwExport, BrwExportError } from "./domain/brwExport.js"; import { getRendition, renditionFile, BASE_RENDITION_PROFILE } from "./domain/renditions.js"; import { listShotRefs, assignShotRef, unassignShotRef, resolveShotRefs, REF_KINDS, ShotRefError } from "./domain/shotRefs.js"; import { SourceKind, SourceRole, FactStatus, ShotRole } from "./domain/types.js"; declare module "fastify" { interface FastifyRequest { user: { id: string; login: string; displayName: string; role: string } | null; } } const COOKIE = "vilmal_session"; // Role ujęć generowane torem fotograficznym (edycja obrazu przez providera AI). // blueprint / channel_graphic / document / source_material to osobne pipeline'y // (Etap C) — nie wolno im przechodzić przez foto-prompt. const GENERATABLE_ROLES = new Set([ "hero", "packshot_front", "packshot_34", "side", "back", "sleep", "storage", "detail", ]); /** * Czytelny stan zadania dla operatora: etap, czas trwania, miejsce * w lokalnej kolejce, status providera (kolejka RunComfy) i liczba prób. * Bez payloadu (referencje/prompt) — to zostaje w szczegółach zadania. */ function jobProgress(db: Db, job: JobRow) { let payload: { providerStatus?: { status: string; queuePosition: number | null; polledAt: string }; submittedAt?: string; prepared?: { submittedAt?: string } } = {}; try { payload = JSON.parse(job.payload_json); } catch { /* uszkodzony payload — pokazujemy sam status */ } const ahead = ["queued", "waiting"].includes(job.status) ? (db.prepare("SELECT COUNT(*) AS c FROM jobs WHERE status IN ('queued','running') AND created_at < ? AND id <> ?").get(job.created_at, job.id) as unknown as { c: number }).c : 0; const submittedAt = payload.submittedAt ?? payload.prepared?.submittedAt ?? null; const end = ["done", "error", "cancelled"].includes(job.status) ? Date.parse(job.updated_at) : Date.now(); const stage = job.status === "done" ? "gotowe" : job.status === "error" ? "błąd" : job.provider_request_id ? payload.providerStatus?.status === "in_queue" ? "w kolejce u dostawcy AI" : "generacja u dostawcy AI" : job.status === "running" ? "przetwarzanie lokalne" : ahead ? "czeka w kolejce" : "rozpoczyna"; return { stage, elapsedSec: Math.max(0, Math.round((end - Date.parse(job.created_at)) / 1000)), providerElapsedSec: submittedAt ? Math.max(0, Math.round((end - Date.parse(submittedAt)) / 1000)) : null, localQueueAhead: ahead, providerStatus: payload.providerStatus?.status ?? null, providerQueuePosition: payload.providerStatus?.queuePosition ?? null, attempts: job.attempts, maxAttempts: job.max_attempts, python: pythonQueueState(), }; } /** Płatne zlecenia od północy (z request id u providera) vs limit dzienny. */ /** Aktywne (queued/running/waiting) zadanie danego typu dla produktu. */ function activeProductJob(db: Db, type: string, productId: string, role?: string) { return db .prepare(`SELECT * FROM jobs WHERE type = ? AND status IN ('queued','running','waiting') AND json_extract(payload_json, '$.productId') = ?${role ? " AND json_extract(payload_json, '$.role') = ?" : ""} ORDER BY created_at DESC LIMIT 1`) .get(type, productId, ...(role ? [role] : [])) as unknown as JobRow | undefined; } function latestProductJob(db: Db, type: string, productId: string) { return db .prepare("SELECT * FROM jobs WHERE type = ? AND json_extract(payload_json, '$.productId') = ? ORDER BY created_at DESC LIMIT 1") .get(type, productId) as unknown as JobRow | undefined; } function paidQuota(db: Db): { used: number; limit: number } { const todayStart = new Date(); todayStart.setHours(0, 0, 0, 0); const used = ( db.prepare("SELECT COUNT(*) AS c FROM jobs WHERE provider = 'runcomfy' AND provider_request_id IS NOT NULL AND created_at >= ?") .get(todayStart.toISOString()) as unknown as { c: number } ).c; return { used, limit: Number(process.env.VILMAL_MAX_PAID_JOBS_PER_DAY ?? 10) }; } function sendFile(reply: FastifyReply, absPath: string, mime: string, download = false) { const stream = fs.createReadStream(absPath); return reply .header("Content-Type", mime) .header("Cache-Control", "private, immutable, max-age=31536000") .header("X-Content-Type-Options", "nosniff") .send(stream); } function safeJoin(base: string, rel: string): string | null { const abs = path.resolve(base, rel); if (!abs.startsWith(path.resolve(base) + path.sep)) return null; return abs; } /** Odbudowa miniatury 480px z oryginału — derived to regenerowalny cache. * Zapis przez plik tymczasowy + rename: równoległe żądania nie dają uciętego webp. */ async function regenThumb(cfg: AppConfig, sha256: string, srcAbs: string): Promise { const thumbAbs = path.join(cfg.derivedDir, sha256.slice(0, 2), `${sha256}-w480.webp`); const tmpAbs = `${thumbAbs}.tmp-${process.pid}`; try { fs.mkdirSync(path.dirname(thumbAbs), { recursive: true }); await sharp(srcAbs).rotate().resize({ width: 480, withoutEnlargement: true }).webp({ quality: 82 }).toFile(tmpAbs); try { fs.renameSync(tmpAbs, thumbAbs); } catch { fs.rmSync(tmpAbs, { force: true }); // równoległa odbudowa zapisała już cache } } catch { return null; } return fs.existsSync(thumbAbs) ? thumbAbs : null; } export function registerRoutes(app: FastifyInstance, db: Db, cfg: AppConfig, deps: { baseAdapter?: BaseAdapter } = {}): void { const baseAdapter = deps.baseAdapter ?? disabledBaseAdapter; /** Asset wytworzony ścieżką studia (engine studio_v1) — tuner idzie wtedy torem studia. */ const isStudioAsset = (assetId: string | undefined): boolean => { if (!assetId) return false; const r = db.prepare("SELECT json_extract(provenance_json, '$.engine') AS e FROM assets WHERE id = ?").get(assetId) as unknown as { e: string | null } | undefined; return r?.e === STUDIO_ENGINE; }; // sesja z ciasteczka na każde żądanie app.addHook("preHandler", async (req) => { req.user = resolveSession(db, req.cookies[COOKIE]); }); registerOrchestratorRoutes(app, db, cfg); registerExportRoutes(app, db, cfg, baseAdapter); registerSettingsRoutes(app, db, cfg, baseAdapter); const requireUser = (req: FastifyRequest, reply: FastifyReply): boolean => { if (!req.user) { reply.code(401).send({ error: "Wymagane zalogowanie." }); return false; } return true; }; // ---------- auth ---------- app.post("/api/auth/login", async (req, reply) => { const body = z.object({ login: z.string().min(1), password: z.string().min(1) }).parse(req.body); const result = login(db, body.login, body.password); if (!result) return reply.code(401).send({ error: "Błędny login lub hasło." }); reply.setCookie(COOKIE, result.sessionId, { path: "/", httpOnly: true, sameSite: "lax", secure: cfg.env === "production", maxAge: 14 * 24 * 3600, }); return { user: result.user }; }); app.post("/api/auth/logout", async (req, reply) => { const sid = req.cookies[COOKIE]; if (sid) logout(db, sid); reply.clearCookie(COOKIE, { path: "/" }); return { ok: true }; }); app.get("/api/auth/me", async (req) => ({ user: req.user, setupRequired: countUsers(db) === 0, })); app.post("/api/source-imports", async (req, reply) => { if (!requireUser(req, reply)) return; reply.header("Cache-Control", "private, no-store"); const parsed = enqueueSourceSchema.safeParse(req.body); if (!parsed.success) return reply.code(400).send({ error: "Wymagane: URL, rodzaj źródła i klucz idempotencji; produkt jest opcjonalny." }); const body = parsed.data; const product = body.productSlug ? getProductBySlug(db, body.productSlug) : null; if (body.productSlug && !product) return reply.code(404).send({ error: "Nie znaleziono produktu." }); try { const result = enqueueSourceImport(db, { url: body.url, kind: body.kind, productId: product?.id ?? null, idempotencyKey: body.idempotencyKey, actor: req.user!.id }); return reply.code(result.created ? 202 : 200).send({ import: sourceImportDto(result.import), created: result.created }); } catch (err) { if (err instanceof SourceFetchError) return reply.code(400).send({ error: err.message }); if (err instanceof SourceImportError) return reply.code(err.statusCode).send({ error: err.message }); throw err; } }); app.get("/api/source-imports", async (req, reply) => { if (!requireUser(req, reply)) return; reply.header("Cache-Control", "private, no-store"); const query = z.object({ limit: z.coerce.number().int().min(1).max(100).default(50) }).safeParse(req.query); if (!query.success) return reply.code(400).send({ error: "Limit musi być liczbą od 1 do 100." }); return { imports: listSourceImports(db, query.data.limit) }; }); app.get("/api/source-imports/:id", async (req, reply) => { if (!requireUser(req, reply)) return; reply.header("Cache-Control", "private, no-store"); const row = getSourceImport(db, (req.params as { id: string }).id); if (!row) return reply.code(404).send({ error: "Nie znaleziono importu." }); return { import: sourceImportDto(row) }; }); app.post("/api/source-imports/:id/cancel", async (req, reply) => { if (!requireUser(req, reply)) return; reply.header("Cache-Control", "private, no-store"); try { return { import: sourceImportDto(cancelSourceImport(db, (req.params as { id: string }).id, req.user!.id)) }; } catch (err) { if (err instanceof SourceImportError) return reply.code(err.statusCode).send({ error: err.message }); throw err; } }); app.get("/api/source-imports/:id/snapshot", async (req, reply) => { if (!requireUser(req, reply)) return; reply.header("Cache-Control", "private, no-store"); try { const snapshot = await readImportSnapshot(db, cfg, (req.params as { id: string }).id); if (!snapshot) return reply.code(404).send({ error: "Snapshot nie jest dostępny." }); const extensions: Record = { "text/html": "html", "application/xhtml+xml": "html", "application/pdf": "pdf", "image/png": "png", "image/jpeg": "jpg", "image/webp": "webp" }; return reply.header("Content-Type", "application/octet-stream") .header("Content-Disposition", `attachment; filename="${snapshot.manifest.importId}.${extensions[snapshot.manifest.mime]}"`) .header("Content-Security-Policy", "sandbox; default-src 'none'") .header("X-Content-Type-Options", "nosniff").send(snapshot.buffer); } catch (err) { if (err instanceof SourceImportError) return reply.code(err.statusCode).send({ error: err.message }); return reply.code(409).send({ error: "Snapshot wymaga weryfikacji pliku." }); } }); app.get("/api/fabrics", async (req, reply) => { if (!requireUser(req, reply)) return; reply.header("Cache-Control", "private, no-store"); return { fabrics: listCatalogFabrics(db) }; }); app.post("/api/fabrics/import", async (req, reply) => { if (!requireUser(req, reply)) return; reply.header("Cache-Control", "private, no-store"); const parsed = enqueueFabricImportSchema.safeParse(req.body); if (!parsed.success) return reply.code(400).send({ error: "Wymagane: URL strony kolekcji i klucz idempotencji; kod tkaniny opcjonalny." }); try { const result = enqueueFabricImport(db, { ...parsed.data, actor: req.user!.id }); return reply.code(result.created ? 202 : 200).send({ import: sourceImportDto(result.import), created: result.created }); } catch (err) { if (err instanceof SourceFetchError || err instanceof FabricImportError || err instanceof SourceImportError) { const code = err instanceof SourceFetchError ? 400 : err.statusCode; return reply.code(code).send({ error: err.message }); } throw err; } }); for (const suffix of ["", "/shades"] as const) { app.get(`/api/fabrics/:code${suffix}`, async (req, reply) => { if (!requireUser(req, reply)) return; reply.header("Cache-Control", "private, no-store"); const fabric = getCatalogFabric(db, (req.params as { code: string }).code); if (!fabric) return reply.code(404).send({ error: "Nie znaleziono tkaniny." }); return suffix ? { shades: catalogShades(db, cfg, fabric) } : catalogDetail(db, cfg, fabric); }); } const verificationSchema = z.object({ verification: z.enum(["confirmed", "unverified"]) }).strict(); app.post("/api/fabrics/:code/verification", async (req, reply) => { if (!requireUser(req, reply)) return; if (!["admin", "approver"].includes(req.user!.role)) return reply.code(403).send({ error: "Weryfikację tkaniny wykonuje rola approver/admin." }); const body = verificationSchema.safeParse(req.body); if (!body.success) return reply.code(400).send({ error: "Wymagane: verification = confirmed | unverified." }); const fabric = setFabricVerification(db, (req.params as { code: string }).code, body.data.verification, req.user!.id); if (!fabric) return reply.code(404).send({ error: "Nie znaleziono tkaniny." }); return { fabric: { id: fabric.id, code: fabric.code, verification: fabric.verification } }; }); app.post("/api/fabrics/:code/shades/:shadeCode/verification", async (req, reply) => { if (!requireUser(req, reply)) return; if (!["admin", "approver"].includes(req.user!.role)) return reply.code(403).send({ error: "Weryfikację odcienia wykonuje rola approver/admin." }); const body = verificationSchema.safeParse(req.body); if (!body.success) return reply.code(400).send({ error: "Wymagane: verification = confirmed | unverified." }); const params = req.params as { code: string; shadeCode: string }; const shade = setShadeVerification(db, params.code, params.shadeCode, body.data.verification, req.user!.id); if (!shade) return reply.code(404).send({ error: "Nie znaleziono odcienia." }); return { shade: { id: shade.id, code: shade.code, verification: shade.verification } }; }); app.post("/api/fabrics/:code/shades/verify-all", async (req, reply) => { if (!requireUser(req, reply)) return; if (!["admin", "approver"].includes(req.user!.role)) return reply.code(403).send({ error: "Weryfikację odcieni wykonuje rola approver/admin." }); const body = z.object({ verification: z.enum(["confirmed", "unverified"]).default("confirmed") }).strict().safeParse(req.body ?? {}); if (!body.success) return reply.code(400).send({ error: "Dozwolone: verification = confirmed | unverified." }); const result = setAllShadesVerification(db, (req.params as { code: string }).code, body.data.verification, req.user!.id); if (!result) return reply.code(404).send({ error: "Nie znaleziono tkaniny." }); return result; }); /** * Nota struktury tkaniny dla generatora AI — operator wybiera typ * struktury z polskiej listy (FABRIC_STRUCTURES) i może dopisać wolną * uwagę po polsku; klauzulę EN (fabrics.ai_prompt → „Fabric note:") * składa system, uwagę PL tłumaczy Groq. Pole opcjonalne — bez noty * strukturę niosą wyłącznie referencje obrazów (sloty 2–3). */ app.put("/api/fabrics/:code/ai-prompt", async (req, reply) => { if (!requireUser(req, reply)) return; const body = z .object({ structure: z.string().trim().max(40).nullable(), notePl: z.string().trim().max(600).nullable(), }) .strict() .safeParse(req.body); if (!body.success) return reply.code(400).send({ error: "Wymagane: structure i notePl (null czyści notę)." }); const { structure, notePl } = body.data; if (structure && !fabricStructureClause(structure)) { return reply.code(400).send({ error: `Nieznany typ struktury: ${structure}.` }); } // Wolna uwaga PL → EN przez Groq; przy braku klucza lub błędzie // providera zapisujemy surowy PL — model image-edit jest wielojęzyczny, // a utrata noty operatora byłaby gorsza niż klauzula PL w prompcie. let noteEn: string | null = null; if (notePl && groqConfigured(cfg)) { try { noteEn = await translateFabricNote(cfg, notePl); } catch { noteEn = null; } } const fabric = setFabricAiNote(db, (req.params as { code: string }).code, { structure, notePl, noteEn }, req.user!.id); if (!fabric) return reply.code(404).send({ error: "Nie znaleziono tkaniny." }); return { fabric: { id: fabric.id, code: fabric.code, aiPrompt: fabric.ai_prompt, aiStructure: fabric.ai_structure, aiNotePl: fabric.ai_note_pl }, }; }); /** * „Zdjęcie Vilmax" odcienia (fabric_shades.photo_path) — PRIORYTET 1 * referencji zachowania tkaniny przy generacji ujęć. Puste pole = * automatyczny fallback na „Próbkę producenta" (official_image_path). * Upload: multipart pole `file` (obraz) → ingest jako materiał globalny * (product_id NULL, rola fabric_reference) i przypięcie do slotu. * JSON { sourceId } przypina istniejący materiał, { sourceId: null } * czyści slot (to samo robi DELETE). */ app.post("/api/fabrics/:code/shades/:shadeCode/photo", async (req, reply) => { if (!requireUser(req, reply)) return; const params = req.params as { code: string; shadeCode: string }; const fabric = getCatalogFabric(db, params.code); if (!fabric) return reply.code(404).send({ error: "Nie znaleziono tkaniny." }); // plugin multipart jest w app.ts; w testowych instancjach bez pluginu // isMultipart może nie istnieć — wtedy obsługujemy tylko JSON. if (typeof req.isMultipart === "function" && req.isMultipart()) { const file = await req.file(); if (!file) return reply.code(400).send({ error: "Wymagany plik obrazu (pole `file`)." }); const buffer = await file.toBuffer(); try { const out = await ingestUpload(db, cfg, { buffer, originalName: file.filename, productId: null, role: "fabric_reference", note: `Zdjęcie Vilmax — ${fabric.name} ${params.shadeCode}.`, uploadedBy: req.user!.id, }); const shade = setShadePhoto(db, params.code, params.shadeCode, out.id, req.user!.id); if (!shade) return reply.code(404).send({ error: "Nie znaleziono odcienia." }); return { shade: { id: shade.id, code: shade.code, photoPath: shade.photo_path }, sourceId: out.id, duplicate: out.duplicate }; } catch (err) { if (err instanceof IngestError) return reply.code(err.statusCode).send({ error: err.message }); throw err; } } const body = z.object({ sourceId: z.string().min(1).max(80).nullable() }).strict().safeParse(req.body); if (!body.success) return reply.code(400).send({ error: "Wymagane: multipart `file` albo JSON { sourceId } (null czyści slot)." }); try { const shade = setShadePhoto(db, params.code, params.shadeCode, body.data.sourceId, req.user!.id); if (!shade) return reply.code(404).send({ error: "Nie znaleziono odcienia." }); return { shade: { id: shade.id, code: shade.code, photoPath: shade.photo_path } }; } catch (err) { return reply.code(400).send({ error: err instanceof Error ? err.message : "Nie przypisano zdjęcia." }); } }); app.delete("/api/fabrics/:code/shades/:shadeCode/photo", async (req, reply) => { if (!requireUser(req, reply)) return; const params = req.params as { code: string; shadeCode: string }; const shade = setShadePhoto(db, params.code, params.shadeCode, null, req.user!.id); if (!shade) return reply.code(404).send({ error: "Nie znaleziono odcienia." }); return { shade: { id: shade.id, code: shade.code, photoPath: null } }; }); for (const suffix of ["/card", "/media/:sourceId", "/media/:sourceId/thumb"] as const) { app.get(`/api/fabrics/:code${suffix}`, async (req, reply) => { if (!requireUser(req, reply)) return; reply.header("Cache-Control", "private, no-store"); const params = req.params as { code: string; sourceId?: string }; const fabric = getCatalogFabric(db, params.code); if (!fabric) return reply.code(404).send({ error: "Nie znaleziono tkaniny." }); const sourceId = suffix === "/card" ? catalogCardSource(db, fabric)?.id : params.sourceId; const media = sourceId ? await catalogMedia(db, cfg, fabric, sourceId, suffix.endsWith("/thumb")) : null; if (!media) return reply.code(404).send({ error: "Brak dostępnego pliku tkaniny." }); return reply.header("Content-Type", media.mime) .header("X-Content-Type-Options", "nosniff") .header("Content-Security-Policy", "sandbox") .send(fs.createReadStream(media.file)); }); } // ---------- produkty ---------- app.get("/api/products", async (req, reply) => { if (!requireUser(req, reply)) return; reply.header("Cache-Control", "private, no-store"); const products = listProducts(db).map((p) => { const counts = db.prepare( `SELECT (SELECT COUNT(*) FROM product_facts f WHERE f.product_id = p.id) AS facts, (SELECT COUNT(*) FROM product_facts f WHERE f.product_id = p.id AND f.status != 'confirmed') AS factsPending, (SELECT COUNT(*) FROM product_facts f WHERE f.product_id = p.id AND f.status = 'confirmed') AS factsConfirmed, (SELECT COUNT(*) FROM variants v WHERE v.product_id = p.id) AS variants, (SELECT COUNT(*) FROM assets a WHERE a.product_id = p.id AND a.status = 'approved') AS approvedAssets, (SELECT COUNT(*) FROM source_imports i WHERE i.product_id = p.id) AS imports FROM products p WHERE p.id = ?` ).get(p.id) as { facts: number; factsPending: number; factsConfirmed: number; variants: number; approvedAssets: number; imports: number }; return { ...p, ...counts }; }); return { products }; }); app.post("/api/products", async (req, reply) => { if (!requireUser(req, reply)) return; const body = z.object({ name: z.string().min(1).max(200), slug: z.string().regex(/^[\w-]{1,120}$/).optional(), kind: z.string().max(80).default("mebel tapicerowany"), }).strict().safeParse(req.body); if (!body.success) return reply.code(400).send({ error: "Wymagana nazwa produktu." }); const derived = body.data.name.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80); const slug = body.data.slug ?? (derived || "produkt"); if (getProductBySlug(db, slug)) return reply.code(409).send({ error: `Slug "${slug}" jest zajęty.` }); const product = createProduct(db, { slug, name: body.data.name, kind: body.data.kind }); audit(db, { actor: req.user!.id, action: "product.created", entity: "products", entityId: product.id, detail: { slug, manual: true } }); return reply.code(201).send({ product }); }); // Usunięcie produktu — operacja bezpowrotna, wymaga approver/admin. // Domena kasuje rekordy zależne w jednej transakcji i pliki po commicie. app.delete("/api/products/:slug", async (req, reply) => { if (!requireUser(req, reply)) return; if (req.user!.role === "operator") { return reply.code(403).send({ error: "Usuwanie produktów wymaga roli approver/admin." }); } try { const result = deleteProduct(db, cfg, { slug: (req.params as { slug: string }).slug, actorId: req.user!.id }); return { deleted: true, ...result }; } catch (err) { if (err instanceof ProductDeleteError) return reply.code(err.statusCode).send({ error: err.message }); throw err; } }); app.post("/api/products/import", async (req, reply) => { if (!requireUser(req, reply)) return; reply.header("Cache-Control", "private, no-store"); const parsed = enqueueProductImportSchema.safeParse(req.body); if (!parsed.success) return reply.code(400).send({ error: "Wymagane: URL produktu i klucz idempotencji; nazwa, slug i tkanina opcjonalne." }); try { const result = enqueueProductImport(db, { ...parsed.data, actor: req.user!.id }); return reply.code(result.created ? 202 : 200).send({ import: sourceImportDto(result.import), product: result.product, created: result.created }); } catch (err) { if (err instanceof SourceFetchError || err instanceof ProductImportError || err instanceof SourceImportError) { const code = err instanceof SourceFetchError ? 400 : err.statusCode; return reply.code(code).send({ error: err.message }); } throw err; } }); // Reimport specyfikacji (i opcjonalnie zdjęć) Bobochic do ISTNIEJĄCEGO // produktu — tryby A "Import Bobochic" i B "Hybryda" w Kroku 1. // importImages=false = tylko fakty/wymiary (zdjęcia operator wgrywa sam). app.post("/api/products/:slug/import", async (req, reply) => { if (!requireUser(req, reply)) return; reply.header("Cache-Control", "private, no-store"); const product = getProductBySlug(db, (req.params as { slug: string }).slug); if (!product) return reply.code(404).send({ error: "Nie znaleziono produktu." }); const parsed = z.object({ url: z.string().min(1).max(2048), /** false = tryb B "Hybryda": tylko fakty/wymiary, bez galerii zdjęć dostawcy */ importImages: z.boolean().optional(), fabricCode: z.string().regex(/^[\w-]{1,40}$/).optional(), idempotencyKey: z.string().regex(/^[\w-]{1,128}$/), }).strict().safeParse(req.body); if (!parsed.success) return reply.code(400).send({ error: "Wymagane: URL produktu i klucz idempotencji; tkanina i importImages opcjonalne." }); try { const result = enqueueProductReimport(db, { productId: product.id, url: parsed.data.url, importImages: parsed.data.importImages, fabricCode: parsed.data.fabricCode, idempotencyKey: parsed.data.idempotencyKey, actor: req.user!.id, }); return reply.code(result.created ? 202 : 200).send({ import: sourceImportDto(result.import), product, created: result.created }); } catch (err) { if (err instanceof SourceFetchError || err instanceof ProductImportError || err instanceof SourceImportError) { const code = err instanceof SourceFetchError ? 400 : err.statusCode; return reply.code(code).send({ error: err.message }); } throw err; } }); const vendorVariantImagesSchema = z.object({ variantUrl: z.string().min(1).max(2048).optional(), noFabricMatch: z.boolean().optional(), }).strict().refine((b) => Boolean(b.variantUrl) !== Boolean(b.noFabricMatch), { message: "Podaj albo variantUrl, albo noFabricMatch — nie oba naraz.", }); // Krok 1 studia: operator wskazuje wizualnie swój wariant (albo „bez // dopasowania tkaniny") po tym, jak import zwrócił images.ambiguous — // dogrywa zdjęcia z JUŻ zapisanego snapshotu, bez ponownego importu faktów. app.post("/api/products/:slug/vendor-images", async (req, reply) => { if (!requireUser(req, reply)) return; reply.header("Cache-Control", "private, no-store"); const product = getProductBySlug(db, (req.params as { slug: string }).slug); if (!product) return reply.code(404).send({ error: "Nie znaleziono produktu." }); const body = vendorVariantImagesSchema.safeParse(req.body); if (!body.success) return reply.code(400).send({ error: body.error.issues[0]?.message ?? "Nieprawidłowe dane." }); try { const result = await fetchVendorVariantImages(db, cfg, { productId: product.id, actor: req.user!.id, ...(body.data.variantUrl ? { variantUrl: body.data.variantUrl } : {}), ...(body.data.noFabricMatch ? { noFabricMatch: body.data.noFabricMatch } : {}), }); return reply.code(200).send({ images: result }); } catch (err) { if (err instanceof ProductImportError) return reply.code(err.statusCode).send({ error: err.message }); throw err; } }); // Ponowne wyciągnięcie zdjęć z JUŻ zapisanego snapshotu (bez pobierania // strony i bez ruszania faktów) — dla produktów z pustą/niepełną galerią. // Duplikaty z innych produktów są linkowane, nie odrzucane. app.post("/api/products/:slug/refetch-images", async (req, reply) => { if (!requireUser(req, reply)) return; const product = getProductBySlug(db, (req.params as { slug: string }).slug); if (!product) return reply.code(404).send({ error: "Nie znaleziono produktu." }); try { const result = await refetchProductImages(db, cfg, { productId: product.id, actor: req.user!.id }); return reply.code(200).send({ images: result }); } catch (err) { if (err instanceof ProductImportError) return reply.code(err.statusCode).send({ error: err.message }); throw err; } }); app.post("/api/products/:slug/fabrics", async (req, reply) => { if (!requireUser(req, reply)) return; const product = getProductBySlug(db, (req.params as { slug: string }).slug); if (!product) return reply.code(404).send({ error: "Nie znaleziono produktu." }); const body = z.object({ fabricCode: z.string().regex(/^[\w-]{1,40}$/), sides: z.array(z.enum(["L", "P", "S"])).min(1).max(2) .refine((s) => !(s.includes("S") && s.length > 1), { message: "S nie łączy się z L/P" }), shadeCodes: z.array(z.string().regex(/^[\w-]{1,12}$/)).min(1).max(100).optional(), prune: z.boolean().optional(), }).strict().safeParse(req.body); if (!body.success) return reply.code(400).send({ error: "Wymagane: fabricCode i strony (L/P albo S — bez stron)." }); const fabric = db.prepare("SELECT id, code FROM fabrics WHERE code = ?").get(body.data.fabricCode) as { id: string; code: string } | undefined; if (!fabric) return reply.code(404).send({ error: "Nie znaleziono tkaniny." }); const keepSides = [...new Set(body.data.sides)] as Array<"L" | "P" | "S">; const pruned = body.data.prune ? pruneVariantSides(db, { productId: product.id, keepSides, actor: req.user!.id }) : null; let matrix; try { matrix = ensureVariantMatrix(db, { productId: product.id, fabricId: fabric.id, sides: keepSides, shadeCodes: body.data.shadeCodes }); } catch (err) { return reply.code(400).send({ error: err instanceof Error ? err.message : String(err) }); } // Warianty powstałe przy pustej puli EAN dostają kody przy każdej // zmianie matrycy — Krok 1 zakłada EAN dla każdego aktywnego koloru. backfillVariantEans(db, product.id, req.user!.id); // Kotwica recoloru (fabric.photographed_shade) nie jest wynikiem żadnego // importu — bez tego wiersza w Specyfikacji technicznej operator nie ma // gdzie jej wpisać. Zakładamy placeholder "unknown" przy pierwszym // powiązaniu tkaniny; nie nadpisujemy, jeśli fakt już istnieje. if (!getFact(db, product.id, "fabric.photographed_shade")) { setFact(db, product.id, { key: "fabric.photographed_shade", value: null, status: "unknown", sourceKind: "owner", sourceRef: null, note: "Odcień fotografowanego egzemplarza — wybierz z katalogu kolekcji (kotwica recoloru).", changedBy: req.user!.id, }); } audit(db, { actor: req.user!.id, action: "product.fabric_bound", entity: "products", entityId: product.id, detail: { fabric: fabric.code, sides: body.data.sides, ...matrix, pruned } }); return { fabric: fabric.code, ...matrix, pruned }; }); // Odjęcie szkicu wariantu z oferty (odznaczony checkbox w Kroku 1). // Tylko status 'draft' bez mediów; EAN wraca do puli. app.delete("/api/products/:slug/variants/:variantId", async (req, reply) => { if (!requireUser(req, reply)) return; const { slug, variantId } = req.params as { slug: string; variantId: string }; const product = getProductBySlug(db, slug); if (!product) return reply.code(404).send({ error: "Nie znaleziono produktu." }); try { const result = deleteDraftVariant(db, { productId: product.id, variantId, actor: req.user!.id }); return { deleted: true, ...result }; } catch (err) { if (err instanceof VariantDeleteError) return reply.code(err.statusCode).send({ error: err.message }); throw err; } }); // Katalog nóżek/stopek — ai_prompt trafia do generowanych ujęć. app.get("/api/furniture-legs", async (req, reply) => { if (!requireUser(req, reply)) return; return { legs: listFurnitureLegs(db) }; }); // Dodanie/aktualizacja nóżki katalogowej. Bez `id` identyfikator // generowany jest sluga z nazwy; istniejący id = aktualizacja rekordu. app.post("/api/furniture-legs", async (req, reply) => { if (!requireUser(req, reply)) return; const body = z.object({ id: z.string().regex(/^[\w-]{1,80}$/).optional(), name: z.string().trim().min(1).max(120), finishType: z.string().trim().min(1).max(80), aiPrompt: z.string().trim().min(1).max(1000), imageAssetId: z.string().regex(/^[\w-]{1,80}$/).optional(), heightCm: z.number().positive().max(100).nullish(), material: z.string().trim().min(1).max(120).nullish(), sourceUrl: z.string().url().max(2048).nullish(), }).strict().safeParse(req.body); if (!body.success) return reply.code(400).send({ error: "Wymagane: name, finishType, aiPrompt (opcjonalnie id, imageAssetId, heightCm, material, sourceUrl)." }); if (body.data.imageAssetId) { const src = db.prepare("SELECT id FROM source_assets WHERE id = ?").get(body.data.imageAssetId) as { id: string } | undefined; if (!src) return reply.code(404).send({ error: "Nie znaleziono zdjęcia w source_assets." }); } const leg = upsertFurnitureLeg(db, { id: body.data.id ?? legIdFromName(body.data.name), name: body.data.name, finishType: body.data.finishType, aiPrompt: body.data.aiPrompt, imageAssetId: body.data.imageAssetId ?? null, heightCm: body.data.heightCm ?? null, material: body.data.material ?? null, sourceUrl: body.data.sourceUrl ?? null, }); audit(db, { actor: req.user!.id, action: "furniture_leg.upserted", entity: "furniture_legs", entityId: leg.id, detail: { name: leg.name, finishType: leg.finish_type } }); return { leg }; }); // Import nóżki ze strony produktu (mir-tex24.pl): strona + zdjęcie do // source_assets (rola leg_reference), rekord furniture_legs z image_asset_id. app.post("/api/furniture-legs/import", async (req, reply) => { if (!requireUser(req, reply)) return; const body = z.object({ url: z.string().trim().min(8).max(2048) }).strict().safeParse(req.body); if (!body.success) return reply.code(400).send({ error: "Wymagane: url strony produktu." }); try { const result = await importLegFromUrl(db, cfg, { url: body.data.url, actor: req.user!.id }); return { leg: result.leg, sourceId: result.sourceId, duplicate: result.duplicate }; } catch (e) { if (e instanceof LegImportError || e instanceof SourceFetchError) { return reply.code(400).send({ error: e.message, code: e.code }); } throw e; } }); // Wybór nóżki dla produktu — fakt legs.furniture_leg_id wskazuje wiersz // furniture_legs używany przy generowaniu ujęć (worker.ts → buildShotRecipe). app.post("/api/products/:slug/legs", async (req, reply) => { if (!requireUser(req, reply)) return; const product = getProductBySlug(db, (req.params as { slug: string }).slug); if (!product) return reply.code(404).send({ error: "Nie znaleziono produktu." }); const body = z.object({ legId: z.string().regex(/^[\w-]{1,80}$/) }).strict().safeParse(req.body); if (!body.success) return reply.code(400).send({ error: "Wymagane: legId." }); const leg = getFurnitureLeg(db, body.data.legId); if (!leg) return reply.code(404).send({ error: "Nie znaleziono nóżki w katalogu furniture_legs." }); const fact = setFact(db, product.id, { key: "legs.furniture_leg_id", value: leg.id, status: "confirmed", sourceKind: "owner", sourceRef: null, note: `Wybrana nóżka katalogowa: ${leg.name}.`, changedBy: req.user!.id, }); audit(db, { actor: req.user!.id, action: "product.leg_set", entity: "products", entityId: product.id, detail: { legId: leg.id, legName: leg.name } }); return { leg, fact }; }); // Pula kodów EAN-13 (GS1) — podgląd stanu i hurtowy import. app.get("/api/ean-pool", async (req, reply) => { if (!requireUser(req, reply)) return; reply.header("Cache-Control", "private, no-store"); const summary = db .prepare( `SELECT COUNT(*) AS total, SUM(CASE WHEN status = 'available' THEN 1 ELSE 0 END) AS available, SUM(CASE WHEN status = 'assigned' THEN 1 ELSE 0 END) AS assigned FROM ean_pool` ) .get() as { total: number; available: number | null; assigned: number | null }; const items = db .prepare( `SELECT e.ean, e.status, e.assigned_at AS assignedAt, e.assigned_variant_id AS variantId, p.name AS productName, v.shade_code AS shadeCode, v.side AS side FROM ean_pool e LEFT JOIN variants v ON v.id = e.assigned_variant_id LEFT JOIN products p ON p.id = v.product_id ORDER BY CASE WHEN e.status = 'assigned' THEN 0 ELSE 1 END, e.assigned_at DESC, e.id DESC LIMIT 500` ) .all() as Array<{ ean: string; status: "available" | "assigned"; assignedAt: string | null; variantId: string | null; productName: string | null; shadeCode: string | null; side: string | null; }>; return { summary: { total: summary.total, available: summary.available ?? 0, assigned: summary.assigned ?? 0 }, items, }; }); app.post("/api/ean-pool", async (req, reply) => { if (!requireUser(req, reply)) return; const body = z .object({ eans: z.array(z.string().max(40)).min(1).max(5000) }) .strict() .safeParse(req.body); if (!body.success) return reply.code(400).send({ error: "Wymagane: eans — tablica kodów EAN-13." }); const result = addEans(db, body.data.eans); // Doładowana pula natychmiast obsługuje warianty czekające bez EAN-a. const backfilled = backfillVariantEans(db, undefined, req.user!.id); audit(db, { actor: req.user!.id, action: "ean_pool.imported", entity: "ean_pool", entityId: "pool", detail: { submitted: body.data.eans.length, added: result.added, skipped: result.skipped, backfilled }, }); return { ...result, backfilled }; }); app.post("/api/products/:slug/facts/confirm", async (req, reply) => { if (!requireUser(req, reply)) return; const product = getProductBySlug(db, (req.params as { slug: string }).slug); if (!product) return reply.code(404).send({ error: "Nie znaleziono produktu." }); if (req.user!.role === "operator") return reply.code(403).send({ error: "Potwierdza rola approver/admin." }); const body = z.object({ keys: z.array(z.string().min(1).max(200)).min(1).max(200), note: z.string().max(1000).nullish(), }).strict().safeParse(req.body); if (!body.success) return reply.code(400).send({ error: "Wymagana lista kluczy faktów." }); const missing = body.data.keys.filter((k) => !getFact(db, product.id, k)); if (missing.length) return reply.code(404).send({ error: `Nieznane klucze faktów: ${missing.join(", ")}` }); const confirmed = body.data.keys.map((key) => { const f = getFact(db, product.id, key)!; if (f.status === "confirmed") return f; return setFact(db, product.id, { key, value: f.value, unit: f.unit, status: "confirmed", sourceKind: f.sourceKind, sourceRef: f.sourceRef, note: body.data.note === undefined ? undefined : body.data.note, changedBy: req.user!.login, }); }); audit(db, { actor: req.user!.id, action: "facts.bulk_confirmed", entity: "products", entityId: product.id, detail: { keys: body.data.keys } }); return { confirmed: confirmed.length, facts: confirmed }; }); app.get("/api/products/:slug", async (req, reply) => { const { slug } = req.params as { slug: string }; const product = getProductBySlug(db, slug); if (!product) return reply.code(404).send({ error: "Nie znaleziono produktu." }); const facts = getFacts(db, product.id); const variants = listVariants(db, product.id); const sources = listSources(db, product.id); const assets = listAssets(db, product.id); const fabric = variants[0] ? (db.prepare("SELECT id, code, name, producer, verification, ai_prompt AS aiPrompt, ai_structure AS aiStructure, ai_note_pl AS aiNotePl FROM fabrics WHERE id = ?").get(variants[0].fabric_id) as { id: string; code: string; name: string; producer: string; verification: string; aiPrompt: string | null; aiStructure: string | null; aiStructureLabel?: string | null; aiNotePl: string | null } | undefined) ?? null : null; if (fabric) fabric.aiStructureLabel = FABRIC_STRUCTURES.find((s) => s.key === fabric.aiStructure)?.label ?? null; const shades = fabric ? listShades(db, fabric.id) : []; const approvedByRole: Record = {}; for (const role of ShotRole.options) { const a = approvedAssetForRole(db, product.id, role); if (a) approvedByRole[role] = a.id; } const macius = latestVerdictsForProduct(db, product.id); return { product, facts, variants, shades, fabric, sources, assets, approvedByRole, macius, shotRefs: listShotRefs(db, product.id), imports: listSourceImports(db, 50).filter((i) => i.productId === product.id) }; }); app.get("/api/products/:slug/offer-content", async (req, reply) => { if (!requireUser(req, reply)) return; const product = getProductBySlug(db, (req.params as { slug: string }).slug); if (!product) return reply.code(404).send({ error: "Nie znaleziono produktu." }); return buildOfferContent(db, product.id); }); app.get("/api/products/:slug/offer-content/revisions", async (req, reply) => { if (!requireUser(req, reply)) return; const product = getProductBySlug(db, (req.params as { slug: string }).slug); if (!product) return reply.code(404).send({ error: "Nie znaleziono produktu." }); return { revisions: listContentRevisions(db, product.id) }; }); app.get("/api/products/:slug/offer-content/revisions/:id", async (req, reply) => { if (!requireUser(req, reply)) return; const params = req.params as { slug: string; id: string }; const product = getProductBySlug(db, params.slug); if (!product) return reply.code(404).send({ error: "Nie znaleziono produktu." }); try { return getContentRevision(db, product.id, params.id); } catch (err) { if (err instanceof ContentConflict) return reply.code(409).send({ error: err.message }); throw err; } }); app.post("/api/products/:slug/offer-content/revisions", async (req, reply) => { if (!requireUser(req, reply)) return; const product = getProductBySlug(db, (req.params as { slug: string }).slug); if (!product) return reply.code(404).send({ error: "Nie znaleziono produktu." }); const body = saveContentSchema.parse(req.body); try { return saveContentRevision(db, { ...body, productId: product.id, actor: req.user!.id }); } catch (err) { if (err instanceof ContentConflict) return reply.code(409).send({ error: err.message }); throw err; } }); app.post("/api/products/:slug/offer-content/revisions/:id/decision", async (req, reply) => { if (!requireUser(req, reply)) return; if (!["admin", "approver"].includes(req.user!.role)) return reply.code(403).send({ error: "Decyzja wymaga roli approver/admin." }); const params = req.params as { slug: string; id: string }; const product = getProductBySlug(db, params.slug); if (!product) return reply.code(404).send({ error: "Nie znaleziono produktu." }); const body = contentDecisionSchema.parse(req.body); try { return decideContentRevision(db, { ...body, productId: product.id, revisionId: params.id, actor: req.user!.id }); } catch (err) { if (err instanceof ContentConflict) return reply.code(409).send({ error: err.message }); throw err; } }); // ---------- paczka ofertowa: lokalny dry-run, bez zapisu do Base ---------- app.get("/api/products/:slug/offer-package", async (req, reply) => { if (!requireUser(req, reply)) return; const product = getProductBySlug(db, (req.params as { slug: string }).slug); if (!product) return reply.code(404).send({ error: "Nie znaleziono produktu." }); try { const pack = buildOfferPackage(db, cfg, product.id); return { ...pack, snapshots: listOfferSnapshots(db, cfg, product.id, pack.sourceSha256).snapshots }; } catch (err) { if (err instanceof PackageConflict) return reply.code(409).send({ error: err.message }); throw err; } }); app.get("/api/products/:slug/offer-package/snapshots", async (req, reply) => { if (!requireUser(req, reply)) return; const product = getProductBySlug(db, (req.params as { slug: string }).slug); if (!product) return reply.code(404).send({ error: "Nie znaleziono produktu." }); return listOfferSnapshots(db, cfg, product.id); }); app.get("/api/products/:slug/offer-package/snapshots/:id", async (req, reply) => { if (!requireUser(req, reply)) return; const params = req.params as { slug: string; id: string }; const product = getProductBySlug(db, params.slug); if (!product) return reply.code(404).send({ error: "Nie znaleziono produktu." }); try { return getOfferSnapshot(db, cfg, product.id, params.id); } catch (err) { if (err instanceof PackageConflict) return reply.code(409).send({ error: err.message }); throw err; } }); app.post("/api/products/:slug/offer-package/snapshots", async (req, reply) => { if (!requireUser(req, reply)) return; const product = getProductBySlug(db, (req.params as { slug: string }).slug); if (!product) return reply.code(404).send({ error: "Nie znaleziono produktu." }); const body = saveSnapshotSchema.parse(req.body); try { return saveOfferSnapshot(db, cfg, { ...body, productId: product.id, actor: req.user!.id }); } catch (err) { if (err instanceof PackageConflict) return reply.code(409).send({ error: err.message }); throw err; } }); app.post("/api/products/:slug/offer-package/snapshots/:id/decision", async (req, reply) => { if (!requireUser(req, reply)) return; if (!["admin", "approver"].includes(req.user!.role)) return reply.code(403).send({ error: "Decyzja wymaga roli approver/admin." }); const params = req.params as { slug: string; id: string }; const product = getProductBySlug(db, params.slug); if (!product) return reply.code(404).send({ error: "Nie znaleziono produktu." }); const body = snapshotDecisionSchema.parse(req.body); try { return decideOfferSnapshot(db, cfg, { ...body, productId: product.id, snapshotId: params.id, actor: req.user!.id }); } catch (err) { if (err instanceof PackageConflict) return reply.code(409).send({ error: err.message }); throw err; } }); app.post("/api/products/:slug/offer-package/renditions", async (req, reply) => { if (!requireUser(req, reply)) return; const product = getProductBySlug(db, (req.params as { slug: string }).slug); if (!product) return reply.code(404).send({ error: "Nie znaleziono produktu." }); const body = z.object({ profile: z.literal(BASE_RENDITION_PROFILE).default(BASE_RENDITION_PROFILE) }).parse(req.body ?? {}); const pack = buildOfferPackage(db, cfg, product.id); const active = db .prepare( `SELECT * FROM jobs WHERE type = 'build_renditions' AND status IN ('queued', 'running', 'waiting') AND json_extract(payload_json, '$.productId') = ? AND json_extract(payload_json, '$.profile') = ? ORDER BY created_at DESC LIMIT 1` ) .get(product.id, body.profile) as unknown as { id: string; status: string } | undefined; if (active) return { job: getJob(db, active.id), alreadyRunning: true }; if (pack.renditions.missing === 0) return { job: null, alreadyRunning: false, renditions: pack.renditions }; const job = enqueueJob(db, { type: "build_renditions", provider: null, idempotencyKey: `renditions:${product.id}:${body.profile}:${pack.sourceSha256}:${pack.renditions.ready}`, payload: { productId: product.id, profile: body.profile }, maxAttempts: 2, }); audit(db, { actor: req.user!.id, action: "offer.renditions.enqueue", entity: "jobs", entityId: job.id, detail: { productId: product.id, profile: body.profile, sourceSha256: pack.sourceSha256 } }); return { job, alreadyRunning: false }; }); app.get("/api/renditions/:id", async (req, reply) => { if (!requireUser(req, reply)) return; const row = getRendition(db, (req.params as { id: string }).id); if (!row) return reply.code(404).send({ error: "Brak renditionu." }); const file = renditionFile(cfg, row); if (!file) return reply.code(404).send({ error: "Plik renditionu nie odpowiada SHA-256." }); return sendFile(reply, file.abs, file.mime); }); app.get("/api/products/:slug/brw-preview", async (req, reply) => { if (!requireUser(req, reply)) return; const product = getProductBySlug(db, (req.params as { slug: string }).slug); if (!product) return reply.code(404).send({ error: "Nie znaleziono produktu." }); reply.header("Cache-Control", "private, no-store"); try { return await previewBrwExport(db, cfg, product.id); } catch (err) { if (err instanceof BrwExportError) return reply.code(409).send({ error: err.message }); throw err; } }); app.get("/api/products/:slug/brw.xlsx", async (req, reply) => { if (!requireUser(req, reply)) return; const product = getProductBySlug(db, (req.params as { slug: string }).slug); if (!product) return reply.code(404).send({ error: "Nie znaleziono produktu." }); try { const { buffer, variantCount, blockers, warnings } = await buildBrwWorkbook(db, cfg, product.id); const safeSlug = product.slug.replace(/[^A-Za-z0-9_-]/g, "_"); reply .header("Cache-Control", "private, no-store") .header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") .header("Content-Disposition", `attachment; filename="brw_${safeSlug}.xlsx"`) .header("X-Brw-Variants", String(variantCount)) .header("X-Brw-Blockers", encodeURIComponent(JSON.stringify(blockers))) .header("X-Brw-Warnings", encodeURIComponent(JSON.stringify(warnings))); return reply.send(buffer); } catch (err) { if (err instanceof BrwExportError) return reply.code(409).send({ error: err.message }); throw err; } }); app.get("/api/products/:slug/media-review", async (req, reply) => { if (!requireUser(req, reply)) return; const product = getProductBySlug(db, (req.params as { slug: string }).slug); if (!product) return reply.code(404).send({ error: "Nie znaleziono produktu." }); return buildMediaReview(db, cfg, product.id); }); app.get("/api/products/:slug/media-review/contact-sheet", async (req, reply) => { if (!requireUser(req, reply)) return; const product = getProductBySlug(db, (req.params as { slug: string }).slug); if (!product) return reply.code(404).send({ error: "Nie znaleziono produktu." }); const { role } = z.object({ role: z.enum(OFFER_ROLES) }).parse(req.query); const sheet = await buildReviewContactSheet(db, cfg, product.id, role); return sendFile(reply, sheet.path, "image/png"); }); app.post("/api/products/:slug/media-review/decision", async (req, reply) => { if (!requireUser(req, reply)) return; if (!["admin", "approver"].includes(req.user!.role)) return reply.code(403).send({ error: "Decyzja wymaga roli approver/admin." }); const product = getProductBySlug(db, (req.params as { slug: string }).slug); if (!product) return reply.code(404).send({ error: "Nie znaleziono produktu." }); const body = z.object({ reviewSha256: z.string().regex(/^[a-f0-9]{64}$/), entries: z.array(z.object({ id: z.string(), sha256: z.string().regex(/^[a-f0-9]{64}$/) })).min(1).max(272), decision: z.enum(["approved", "rejected"]), note: z.string().trim().min(1).max(2000), }).parse(req.body); try { return { approvals: decideMediaReview(db, cfg, { ...body, productId: product.id, actor: req.user!.id }) }; } catch (err) { if (err instanceof ReviewConflict) return reply.code(409).send({ error: err.message }); throw err; } }); app.post("/api/products/:slug/media-review/prescreen", async (req, reply) => { if (!requireUser(req, reply)) return; const product = getProductBySlug(db, (req.params as { slug: string }).slug); if (!product) return reply.code(404).send({ error: "Nie znaleziono produktu." }); const body = z.object({ reviewSha256: z.string().regex(/^[a-f0-9]{64}$/) }).parse(req.body); const review = buildMediaReview(db, cfg, product.id); if (review.reviewSha256 !== body.reviewSha256) return reply.code(409).send({ error: "Odśwież nieaktualny przegląd." }); const entries = review.slots.flatMap((s) => s.asset && !s.asset.qa && !s.asset.blockers.length ? [{ id: s.asset.id, sha256: s.asset.sha256 }] : []); if (!entries.length) return reply.code(422).send({ error: "Brak nieocenionych plików bez blokad integralności." }); const job = enqueueJob(db, { type: "review_media_batch", provider: "macius-rules", idempotencyKey: `prescreen:${product.id}:${body.reviewSha256}`, payload: { productId: product.id, entries, actor: req.user!.id }, }); return { job: { id: job.id, status: job.status } }; }); // ---------- fakty produktu ---------- app.put("/api/products/:slug/facts/:key", async (req, reply) => { if (!requireUser(req, reply)) return; const { slug, key } = req.params as { slug: string; key: string }; const product = getProductBySlug(db, slug); if (!product) return reply.code(404).send({ error: "Nie znaleziono produktu." }); const body = z .object({ value: z.unknown(), status: FactStatus, unit: z.string().max(20).nullish(), sourceKind: SourceKind.optional(), sourceRef: z.string().max(500).nullish(), note: z.string().max(1000).nullish(), }) .parse(req.body); // Potwierdzenie faktu to decyzja merytoryczna — wymaga approvera/admina. if (body.status === "confirmed" && req.user!.role === "operator") { return reply.code(403).send({ error: "Potwierdza rola approver/admin." }); } if (body.status === "confirmed" && !body.sourceRef && !body.sourceKind) { // Kotwica recoloru: mebel z importu nie ma fizycznej metki — dowodem jest // świadomy wybór odcienia z katalogu kolekcji przez zatwierdzającego. // Pozostałe fakty nadal wymagają jawnego źródła. if (key === "fabric.photographed_shade") { body.sourceKind = "owner"; body.sourceRef = "wybór odcienia z katalogu kolekcji (panel produktu)"; } else { return reply.code(422).send({ error: "Potwierdzenie wymaga źródła (sourceKind/sourceRef)." }); } } const fact = setFact(db, product.id, { key, value: body.value === undefined ? null : body.value, unit: body.unit === undefined ? undefined : body.unit, status: body.status, sourceKind: body.sourceKind ?? "owner", sourceRef: body.sourceRef === undefined ? undefined : body.sourceRef, note: body.note === undefined ? undefined : body.note, changedBy: req.user!.login, }); return { fact }; }); // ---------- upload materiałów ---------- app.post("/api/products/:slug/sources", async (req, reply) => { if (!requireUser(req, reply)) return; const { slug } = req.params as { slug: string }; const product = getProductBySlug(db, slug); if (!product) return reply.code(404).send({ error: "Nie znaleziono produktu." }); const parts = req.parts(); let roleRaw: string | null = null; let note: string | null = null; let assignRole: string | null = null; const results: unknown[] = []; const errors: string[] = []; for await (const part of parts) { if (part.type === "file") { const buffer = await part.toBuffer(); const parsed = SourceRole.safeParse(roleRaw); if (!parsed.success) { errors.push(`${part.filename}: brak/błędna rola materiału`); continue; } try { const out = await ingestUpload(db, cfg, { buffer, originalName: part.filename, productId: product.id, role: parsed.data, note, uploadedBy: req.user!.id, }); // "Wgraj zdjęcie pod to ujęcie" — opcjonalne auto-przypisanie jako primary const shotRole = assignRole ? ShotRole.safeParse(assignRole) : null; if (shotRole?.success && GENERATABLE_ROLES.has(shotRole.data)) { assignShotRef(db, { productId: product.id, shotRole: shotRole.data, sourceId: out.id, refKind: "primary", actor: req.user!.id, }); } audit(db, { actor: req.user!.id, action: "source.upload", entity: "source_assets", entityId: out.id, detail: { role: parsed.data, name: part.filename, sha256: out.sha256, duplicate: out.duplicate }, }); results.push(out); } catch (err) { if (err instanceof IngestError) errors.push(`${part.filename}: ${err.message}`); else throw err; } } else if (part.fieldname === "role") { roleRaw = String(part.value); } else if (part.fieldname === "note") { note = String(part.value); } else if (part.fieldname === "assignRole") { assignRole = String(part.value); } } return reply.code(errors.length && !results.length ? 422 : 200).send({ uploaded: results, errors, }); }); // ---------- korekta materiału źródłowego (notatka / odpięcie od produktu) ---------- app.patch("/api/sources/:id", async (req, reply) => { if (!requireUser(req, reply)) return; const { id } = req.params as { id: string }; const row = db.prepare("SELECT id, product_id FROM source_assets WHERE id = ?").get(id) as unknown as | { id: string; product_id: string | null } | undefined; if (!row) return reply.code(404).send({ error: "Brak materiału." }); const body = z .object({ note: z.string().max(1000).nullish(), detach: z.boolean().optional(), // product_id → NULL (materiał ogólny / błędne przypisanie) }) .parse(req.body); db.prepare("UPDATE source_assets SET note = COALESCE(?, note) WHERE id = ?").run( body.note === undefined ? null : body.note, id ); if (body.detach) { db.prepare("UPDATE source_assets SET product_id = NULL WHERE id = ?").run(id); } audit(db, { actor: req.user!.id, action: "source.update", entity: "source_assets", entityId: id, detail: { detached: Boolean(body.detach), note: body.note ?? undefined }, }); return { source: db.prepare("SELECT * FROM source_assets WHERE id = ?").get(id) }; }); // Odpięcie materiału od produktu (kosz w menedżerze zdjęć Kroku 1). // Oryginał i plik zostają nietknięte (niezmienne źródła); znika powiązanie // z produktem (product_id + link source_asset_products) i przypisania // tego źródła do ról ujęć tego produktu. app.delete("/api/products/:slug/sources/:sourceId", async (req, reply) => { if (!requireUser(req, reply)) return; const { slug, sourceId } = req.params as { slug: string; sourceId: string }; const product = getProductBySlug(db, slug); if (!product) return reply.code(404).send({ error: "Nie znaleziono produktu." }); const linked = db.prepare( `SELECT sa.id FROM source_assets sa WHERE sa.id = ? AND (sa.product_id = ? OR EXISTS (SELECT 1 FROM source_asset_products l WHERE l.source_id = sa.id AND l.product_id = ?))` ).get(sourceId, product.id, product.id) as { id: string } | undefined; if (!linked) return reply.code(404).send({ error: "Materiał nie jest powiązany z tym produktem." }); const removedRefs = tx(db, () => { db.prepare("UPDATE source_assets SET product_id = NULL WHERE id = ? AND product_id = ?").run(sourceId, product.id); db.prepare("DELETE FROM source_asset_products WHERE source_id = ? AND product_id = ?").run(sourceId, product.id); return db.prepare("DELETE FROM shot_refs WHERE product_id = ? AND source_id = ?").run(product.id, sourceId).changes; }); audit(db, { actor: req.user!.id, action: "source.detached", entity: "source_assets", entityId: sourceId, detail: { productId: product.id, removedShotRefs: Number(removedRefs) }, }); return { detached: true, removedShotRefs: Number(removedRefs) }; }); // ---------- przypisania źródeł do ról ujęć ("zdjęcie pod ujęcie") ---------- app.post("/api/products/:slug/shot-refs", async (req, reply) => { if (!requireUser(req, reply)) return; const product = getProductBySlug(db, (req.params as { slug: string }).slug); if (!product) return reply.code(404).send({ error: "Nie znaleziono produktu." }); const body = z .object({ shotRole: ShotRole, sourceId: z.string().min(1), refKind: z.enum(REF_KINDS).default("primary"), note: z.string().max(500).nullish(), }) .parse(req.body); if (!GENERATABLE_ROLES.has(body.shotRole)) { return reply.code(422).send({ error: `Rola „${body.shotRole}" nie jest ujęciem fotograficznym.` }); } try { return { ref: assignShotRef(db, { productId: product.id, shotRole: body.shotRole, sourceId: body.sourceId, refKind: body.refKind, note: body.note, actor: req.user!.id, }), }; } catch (err) { if (err instanceof ShotRefError) return reply.code(422).send({ error: err.message }); throw err; } }); app.delete("/api/products/:slug/shot-refs/:id", async (req, reply) => { if (!requireUser(req, reply)) return; const params = req.params as { slug: string; id: string }; const product = getProductBySlug(db, params.slug); if (!product) return reply.code(404).send({ error: "Nie znaleziono produktu." }); try { unassignShotRef(db, { id: params.id, productId: product.id, actor: req.user!.id }); return { ok: true }; } catch (err) { if (err instanceof ShotRefError) return reply.code(404).send({ error: err.message }); throw err; } }); // ---------- media ---------- app.get("/api/media/source/:id", async (req, reply) => { const { id } = req.params as { id: string }; const row = db.prepare("SELECT path, mime FROM source_assets WHERE id = ?").get(id) as unknown as | { path: string; mime: string } | undefined; if (!row) return reply.code(404).send({ error: "Brak pliku." }); const abs = safeJoin(cfg.originalsDir, row.path); if (!abs || !fs.existsSync(abs)) return reply.code(404).send({ error: "Brak pliku na dysku." }); return sendFile(reply, abs, row.mime); }); app.get("/api/media/source/:id/thumb", async (req, reply) => { const { id } = req.params as { id: string }; const row = db.prepare("SELECT thumb_path, path, mime, sha256 FROM source_assets WHERE id = ?").get(id) as unknown as | { thumb_path: string | null; path: string; mime: string; sha256: string } | undefined; if (!row) return reply.code(404).send({ error: "Brak pliku." }); let abs = row.thumb_path ? safeJoin(cfg.derivedDir, row.thumb_path) : null; if (!abs || !fs.existsSync(abs)) { // starsze rekordy bez thumb_path / wyczyszczony cache → odbudowa z oryginału if (!row.mime.startsWith("image/")) return reply.code(404).send({ error: "Brak miniatury." }); const srcAbs = safeJoin(cfg.originalsDir, row.path); abs = srcAbs ? await regenThumb(cfg, row.sha256, srcAbs) : null; if (!abs) return reply.code(404).send({ error: "Brak miniatury." }); db.prepare("UPDATE source_assets SET thumb_path = ? WHERE id = ?").run(path.relative(cfg.derivedDir, abs), id); } return sendFile(reply, abs, "image/webp"); }); app.get("/api/media/asset/:id/thumb", async (req, reply) => { const { id } = req.params as { id: string }; const row = db.prepare("SELECT thumb_path, path, mime, sha256 FROM assets WHERE id = ?").get(id) as unknown as | { thumb_path: string | null; path: string; mime: string; sha256: string } | undefined; if (!row) return reply.code(404).send({ error: "Brak pliku." }); let abs = row.thumb_path ? safeJoin(cfg.derivedDir, row.thumb_path) : null; if (!abs || !fs.existsSync(abs)) { // starsze rekordy bez thumb_path / wyczyszczony cache → odbudowa z oryginału if (!row.mime.startsWith("image/")) return reply.code(404).send({ error: "Brak miniatury." }); const srcAbs = safeJoin(cfg.mediaDir, row.path); abs = srcAbs ? await regenThumb(cfg, row.sha256, srcAbs) : null; if (!abs) return reply.code(404).send({ error: "Brak miniatury." }); db.prepare("UPDATE assets SET thumb_path = ? WHERE id = ?").run(path.relative(cfg.derivedDir, abs), id); } return sendFile(reply, abs, "image/webp"); }); app.get("/api/media/asset/:id", async (req, reply) => { const { id } = req.params as { id: string }; const row = db.prepare("SELECT path, mime FROM assets WHERE id = ?").get(id) as unknown as | { path: string; mime: string } | undefined; if (!row) return reply.code(404).send({ error: "Brak pliku." }); const abs = safeJoin(cfg.mediaDir, row.path); if (!abs || !fs.existsSync(abs)) return reply.code(404).send({ error: "Brak pliku na dysku." }); return sendFile(reply, abs, row.mime); }); // ---------- decyzje / zatwierdzenia ---------- app.post("/api/assets/:id/decision", async (req, reply) => { if (!requireUser(req, reply)) return; if (req.user!.role === "operator") { return reply.code(403).send({ error: "Zatwierdza rola approver/admin." }); } const { id } = req.params as { id: string }; const body = z .object({ decision: z.enum(["approved", "rejected"]), expectedSha256: z.string().regex(/^[a-f0-9]{64}$/), note: z.string().optional() }) .parse(req.body); const asset = db.prepare("SELECT * FROM assets WHERE id = ?").get(id) as unknown as | { id: string; product_id: string; role: string; variant_id: string | null; sha256: string } | undefined; if (!asset) return reply.code(404).send({ error: "Nie znaleziono assetu." }); if (asset.sha256 !== body.expectedSha256) return reply.code(409).send({ error: "Treść zmieniła się od podglądu." }); if (body.decision === "approved" && (asset.variant_id || asset.role === "document")) { const review = buildMediaReview(db, cfg, asset.product_id); const selected = [...review.slots.flatMap((s) => [...(s.asset ? [s.asset] : []), ...s.candidates]), ...review.documents].find((a) => a.id === asset.id); if (!selected || selected.blockers.length) return reply.code(409).send({ error: "Materiał jest nieaktualny lub ma blokady. Otwórz przegląd mediów." }); } const approval = decide(db, { productId: asset.product_id, scope: asset.variant_id ? "variant_media" : asset.role === "document" ? "document" : "shot", variantId: asset.variant_id, target: asset.role, assetId: asset.id, expectedSha256: body.expectedSha256, decision: body.decision, decidedBy: req.user!.id, note: body.note ?? null, }); return { approval }; }); // ---------- przywracanie odrzuconej wersji do oceny ---------- app.post("/api/assets/:id/restore", async (req, reply) => { if (!requireUser(req, reply)) return; const { id } = req.params as { id: string }; try { const asset = restoreCandidate(db, id, req.user!.id); return { asset }; } catch (err) { return reply.code(422).send({ error: err instanceof Error ? err.message : "Błąd przywracania." }); } }); // ---------- Maciuś: doradcza ocena wizyjna assetu (Groq) ---------- app.post("/api/assets/:id/review", async (req, reply) => { if (!requireUser(req, reply)) return; const { id } = req.params as { id: string }; try { const verdict = await reviewAsset(db, cfg, { assetId: id, createdBy: req.user!.id }); return { verdict }; } catch (err) { return reply.code(422).send({ error: err instanceof Error ? err.message : "Błąd oceny Maciusia." }); } }); // ---------- generacja ujęcia (płatne — limit dzienny) ---------- app.post("/api/products/:slug/shots/generate", async (req, reply) => { if (!requireUser(req, reply)) return; const { slug } = req.params as { slug: string }; const product = getProductBySlug(db, slug); if (!product) return reply.code(404).send({ error: "Nie znaleziono produktu." }); if (!cfg.runcomfyApiKey) { return reply.code(503).send({ error: "Brak RUNCOMFY_API_KEY — provider AI nieaktywny." }); } const body = z .object({ role: ShotRole, sourceIds: z.array(z.string()).min(1).max(8).optional(), promptOverride: z.string().max(4000).optional(), }) .parse(req.body); if (!GENERATABLE_ROLES.has(body.role)) { return reply.code(422).send({ error: `Rola „${body.role}" nie jest generowana torem fotograficznym — to osobny pipeline (Etap C).`, }); } // limit dzienny płatnych zleceń — domyślnie 10 const quota = paidQuota(db); if (quota.used >= quota.limit) { return reply.code(429).send({ error: `Dzienny limit płatnych zleceń (${quota.limit}) wyczerpany. Zwiększ VILMAL_MAX_PAID_JOBS_PER_DAY po uzgodnieniu.`, }); } // Referencje: jawny override (sourceIds) albo auto-resolve ze shot_refs. // Ujęcie foto wymaga primary — referencja MUSI pokazywać dokładnie oczekiwany // stan mebla (wniosek z halucynacji storage). Brak primary = jawny błąd, // nie cichy fallback na dowolne źródło produktu. const resolved = resolveShotRefs(db, product.id, body.role); let sourceIds: string[]; let resolvedFrom: "shot_refs" | "explicit"; let primarySourceId: string | null; if (body.sourceIds?.length) { sourceIds = body.sourceIds; resolvedFrom = "explicit"; primarySourceId = resolved.primaryId && sourceIds.includes(resolved.primaryId) ? resolved.primaryId : null; } else { if (!resolved.primaryId) { return reply.code(422).send({ error: `Ujęcie „${body.role}" nie ma przypisanej referencji (primary). Przypisz zdjęcie pod ujęcie w Materiałach albo wskaż sourceIds jawnie.`, missingPrimary: true, }); } sourceIds = resolved.sourceIds; resolvedFrom = "shot_refs"; primarySourceId = resolved.primaryId; } // tylko istniejące źródła tego produktu (własne lub linkowane) const placeholders = sourceIds.map(() => "?").join(","); const found = db .prepare( `SELECT id FROM source_assets WHERE id IN (${placeholders}) AND (product_id = ? OR EXISTS (SELECT 1 FROM source_asset_products l WHERE l.source_id = source_assets.id AND l.product_id = ?))` ) .all(...sourceIds, product.id, product.id) as unknown as Array<{ id: string }>; if (found.length !== sourceIds.length) { return reply.code(422).send({ error: "Część referencji nie istnieje lub nie należy do produktu." }); } // Zdjęcie kadru z flagą [fabric_pending_change] pokazuje INNĄ tkaninę niż // docelowa — bez referencji tkaniny (sloty 2/3) wygenerowany master byłby // po prostu błędny. Blokujemy płatne zlecenie zamiast cicho zgadywać. const fabricRefs = resolveFabricRefs(db, product.id); if (!fabricRefs.fabricSwatchRefId && !fabricRefs.fabricRealRefId) { const flagged = db .prepare( `SELECT COUNT(*) AS n FROM source_assets WHERE id IN (${placeholders}) AND note LIKE '%[fabric_pending_change]%'` ) .get(...sourceIds) as unknown as { n: number }; if (flagged.n > 0) { return reply.code(422).send({ error: "Zdjęcie kadru pokazuje inną tkaninę niż docelowa, a produkt nie ma referencji tkaniny — master powstałby w złej tkaninie. " + "Wybierz odcień fotografowanego egzemplarza w Kroku 1 i uzupełnij próbkę producenta / „Zdjęcie Vilmax” w katalogu tkanin.", code: "fabric_refs_missing", warnings: fabricRefs.warnings, }); } } const job = enqueueJob(db, { type: "generate_shot", provider: "runcomfy", idempotencyKey: `gen:${product.id}:${body.role}:${[...sourceIds].sort().join(",")}:${Date.now()}`, payload: { productId: product.id, role: body.role, sourceIds, primarySourceId, resolvedFrom, refKinds: resolved.refKinds, ...(body.promptOverride ? { prompt: body.promptOverride } : {}), }, }); audit(db, { actor: req.user!.id, action: "shot.generate.enqueue", entity: "jobs", entityId: job.id, detail: { productId: product.id, role: body.role, sourceIds, resolvedFrom, primarySourceId }, }); return { job }; }); // ---------- 4 sloty referencji ujęcia (odczyt; bez generacji i kosztów) ---------- app.get("/api/products/:slug/shots/:role/slots", async (req, reply) => { if (!requireUser(req, reply)) return; const { slug, role } = req.params as { slug: string; role: string }; const product = getProductBySlug(db, slug); if (!product) return reply.code(404).send({ error: "Nie znaleziono produktu." }); const shotRole = ShotRole.safeParse(role); if (!shotRole.success || !GENERATABLE_ROLES.has(shotRole.data)) { return reply.code(422).send({ error: "Ta rola nie jest generowana torem fotograficznym." }); } const gen = shotGenContext(db, product.id); const slots = shotRefSlots(gen, resolveShotRefs(db, product.id, shotRole.data).primaryId); const legId = getFact(db, product.id, "legs.furniture_leg_id")?.value; const leg = legId ? db.prepare("SELECT name FROM furniture_legs WHERE id = ?").get(String(legId)) as unknown as { name: string } | undefined : undefined; return { role: shotRole.data, slots, legName: leg?.name ?? null, anchorShade: (getFact(db, product.id, "fabric.photographed_shade")?.value as string | null | undefined) ?? null, warnings: gen.refWarnings, }; }); // ---------- Asystent Korekty Kadrów (Groq) — podgląd nakładki ---------- // Zwraca proponowane ephemeral_overrides do podglądu; niczego nie zapisuje // i nie zleca generacji (darmowa analiza tekstowa LLM). app.post("/api/products/:slug/shots/:role/tune-preview", async (req, reply) => { if (!requireUser(req, reply)) return; const { slug, role } = req.params as { slug: string; role: string }; const product = getProductBySlug(db, slug); if (!product) return reply.code(404).send({ error: "Nie znaleziono produktu." }); const shotRole = ShotRole.safeParse(role); if (!shotRole.success || !GENERATABLE_ROLES.has(shotRole.data)) { return reply.code(422).send({ error: "Ta rola nie jest generowana torem fotograficznym." }); } if (!groqConfigured(cfg)) { return reply.code(503).send({ error: "Brak GROQ_API_KEY — asystent korekty nieaktywny." }); } const body = z .object({ feedback: z.string().min(3).max(2000), currentAssetId: z.string().max(64).optional(), history: z.array(ChatTurn).max(40).optional(), draft: ShotOverrides.nullish(), }) .parse(req.body); const session = { history: body.history ?? [], draft: body.draft ?? null }; const versions = (db .prepare("SELECT version, status, note FROM assets WHERE product_id = ? AND role = ? AND variant_id IS NULL ORDER BY version DESC LIMIT 8") .all(product.id, shotRole.data) as unknown as Array<{ version: number; status: string; note: string | null }>); const gen = shotGenContext(db, product.id); const fabric = db .prepare("SELECT f.name FROM variants v JOIN fabrics f ON f.id = v.fabric_id WHERE v.product_id = ? LIMIT 1") .get(product.id) as unknown as { name: string } | undefined; // Ujęcie ze ścieżki studia: tuner koryguje prompt sceny/materiału — // bryła i tak wraca z pikseli źródła, a ujęcia bez AI nie mają promptu. if (isStudioAsset(body.currentAssetId)) { const plan = planStudioShot(db, product.id, shotRole.data); if (!plan.paid) return reply.code(422).send({ error: "To ujęcie powstaje ze zdjęcia hali bez AI — korekta promptu nie ma zastosowania. Popraw zdjęcie źródłowe lub obszar wykluczenia." }); if (plan.blocked) return reply.code(422).send({ error: plan.blocked }); const basePrompt = plan.method === "room_lock" ? roomScenePrompt() : materialSwapPrompt(shotRole.data); const ctx: ShotContext = { productName: gen.productName, role: shotRole.data, roleLabel: shotRole.data, fabricName: fabric?.name ?? null, fabricPrompt: "", legsNote: "", basePrompt, versions, refs: plan.inputs.map((r, i) => ({ id: r.id, kind: i === 0 ? ("primary" as const) : ("fabric" as const), label: r.label })), }; try { const overrides = await tuneShotPrompt(cfg, ctx, body.feedback, session); overrides.disabled_ref_ids = []; // wejścia studia są obowiązkowe audit(db, { actor: req.user!.id, action: "shot.tune_preview", entity: "products", entityId: product.id, detail: { role: shotRole.data, currentAssetId: body.currentAssetId ?? null, feedback: body.feedback.slice(0, 500), overrides, studio: plan.method } }); return { overrides, prompt: { base: basePrompt, final: applyShotOverrides(basePrompt, overrides) } }; } catch (err) { return reply.code(502).send({ error: err instanceof Error ? err.message : "Błąd analizy asystenta." }); } } const resolved = resolveShotRefs(db, product.id, shotRole.data); if (!resolved.primaryId) { return reply.code(422).send({ error: `Ujęcie „${shotRole.data}" nie ma przypisanej referencji (primary). Przypisz zdjęcie pod ujęcie w Materiałach.`, missingPrimary: true, }); } // Kontekst identyczny jak przy realnej generacji — LLM widzi faktyczny // prompt bazowy i faktyczną listę referencji (z ich rolą w kadrze). const orderedIds = orderedShotRefIds(shotRefSlots(gen, resolved.primaryId), resolved.sourceIds); const refLabel = (id: string) => id === resolved.primaryId ? { kind: "primary" as const, label: "slot 1 — kadr mebla (geometria)" } : id === gen.fabricSwatchRefId ? { kind: "fabric" as const, label: "slot 2 — próbka tkaniny" } : id === gen.fabricRealRefId ? { kind: "fabric" as const, label: "slot 3 — tkanina na meblu" } : id === gen.legRefId ? { kind: "legs" as const, label: "slot 4 — nóżki" } : { kind: "supporting" as const, label: "referencja pomocnicza" }; const ctx: ShotContext = { productName: gen.productName, role: shotRole.data, roleLabel: shotRole.data, fabricName: fabric?.name ?? null, fabricPrompt: gen.fabricPrompt, legsNote: gen.legsNote, basePrompt: shotRecipePrompt(shotRole.data, gen, orderedIds, resolved.primaryId), versions, refs: orderedIds.map((id) => ({ id, ...refLabel(id) })), }; try { const overrides = await tuneShotPrompt(cfg, ctx, body.feedback, session); audit(db, { actor: req.user!.id, action: "shot.tune_preview", entity: "products", entityId: product.id, detail: { role: shotRole.data, currentAssetId: body.currentAssetId ?? null, feedback: body.feedback.slice(0, 500), overrides }, }); return { overrides, prompt: { base: ctx.basePrompt, final: applyShotOverrides(ctx.basePrompt, overrides) } }; } catch (err) { return reply.code(502).send({ error: err instanceof Error ? err.message : "Błąd analizy asystenta." }); } }); // ---------- Asystent Korekty Kadrów — generacja z nakładką (płatne) ---------- // Zatwierdzone przez operatora overrides lądują w jobs.payload jako // ephemeral_overrides — receptura bazowa i konfiguracja mebla nietknięte. app.post("/api/products/:slug/shots/:role/tune-generate", async (req, reply) => { if (!requireUser(req, reply)) return; const { slug, role } = req.params as { slug: string; role: string }; const product = getProductBySlug(db, slug); if (!product) return reply.code(404).send({ error: "Nie znaleziono produktu." }); const shotRole = ShotRole.safeParse(role); if (!shotRole.success || !GENERATABLE_ROLES.has(shotRole.data)) { return reply.code(422).send({ error: "Ta rola nie jest generowana torem fotograficznym." }); } if (!cfg.runcomfyApiKey) { return reply.code(503).send({ error: "Brak RUNCOMFY_API_KEY — provider AI nieaktywny." }); } const body = z .object({ overrides: ShotOverrides, feedback: z.string().max(2000).optional(), currentAssetId: z.string().max(64).optional(), }) .parse(req.body); const o = body.overrides; const empty = !o.prompt_additions.trim() && !o.negative_prompt_additions.trim() && o.disabled_ref_ids.length === 0; if (empty) { return reply.code(422).send({ error: "Pusta nakładka — użyj zwykłej generacji." }); } // limit dzienny płatnych zleceń — identyczny jak /shots/generate const quota = paidQuota(db); if (quota.used >= quota.limit) { return reply.code(429).send({ error: `Dzienny limit płatnych zleceń (${quota.limit}) wyczerpany. Zwiększ VILMAL_MAX_PAID_JOBS_PER_DAY po uzgodnieniu.`, }); } if (isStudioAsset(body.currentAssetId)) { try { const { job } = enqueueStudioShot(db, { productId: product.id, role: shotRole.data, actor: req.user!.id, overrides: { ...o, disabled_ref_ids: [] } }); audit(db, { actor: req.user!.id, action: "shot.tune_generate.enqueue", entity: "jobs", entityId: job.id, detail: { productId: product.id, role: shotRole.data, currentAssetId: body.currentAssetId ?? null, feedback: body.feedback?.slice(0, 500) ?? null, overrides: o, studio: true } }); return { job }; } catch (err) { return reply.code(422).send({ error: err instanceof Error ? err.message : "Nie zlecono korekty." }); } } // Referencje wyłącznie z przypisań — tuner koryguje ujęcie, nie zmienia // doboru źródeł. Primary jest obowiązkowe jak przy zwykłej generacji. const resolved = resolveShotRefs(db, product.id, shotRole.data); if (!resolved.primaryId) { return reply.code(422).send({ error: `Ujęcie „${shotRole.data}" nie ma przypisanej referencji (primary).`, missingPrimary: true, }); } // Sanityzacja wykluczeń: wolno wyłączyć tylko realne referencje tego // ujęcia, nigdy primary (kotwicę geometrii chroni też worker). const gen = shotGenContext(db, product.id); const disableable = new Set(orderedShotRefIds(shotRefSlots(gen, resolved.primaryId), resolved.sourceIds)); disableable.delete(resolved.primaryId); const overrides: ShotOverridesT = { ...o, disabled_ref_ids: o.disabled_ref_ids.filter((id) => disableable.has(id)), }; const job = enqueueJob(db, { type: "generate_shot", provider: "runcomfy", idempotencyKey: `gen:${product.id}:${shotRole.data}:${[...resolved.sourceIds].sort().join(",")}:tuned:${Date.now()}`, payload: { productId: product.id, role: shotRole.data, sourceIds: resolved.sourceIds, primarySourceId: resolved.primaryId, resolvedFrom: "shot_refs" as const, refKinds: resolved.refKinds, ephemeral_overrides: overrides, }, }); audit(db, { actor: req.user!.id, action: "shot.tune_generate.enqueue", entity: "jobs", entityId: job.id, detail: { productId: product.id, role: shotRole.data, currentAssetId: body.currentAssetId ?? null, feedback: body.feedback?.slice(0, 500) ?? null, overrides, }, }); return { job }; }); // ---------- studio ze zdjęcia hali ---------- // Plan ujęć: metoda, koszt, wejścia (Image 1/2) i blokady — dla planszy kadrów. app.get("/api/products/:slug/studio", async (req, reply) => { if (!requireUser(req, reply)) return; const product = getProductBySlug(db, (req.params as { slug: string }).slug); if (!product) return reply.code(404).send({ error: "Nie znaleziono produktu." }); const baseId = materialBaseProduct(db, product.id); const base = baseId ? (db.prepare("SELECT slug, name FROM products WHERE id = ?").get(baseId) as unknown as { slug: string; name: string }) : null; return { plans: STUDIO_ROLES.map((r) => planStudioShot(db, product.id, r)).map((p) => ({ ...p, methodLabel: METHOD_LABEL[p.method] })), baseProduct: base, quota: paidQuota(db), }; }); app.post("/api/products/:slug/studio/:role", async (req, reply) => { if (!requireUser(req, reply)) return; const { slug, role } = req.params as { slug: string; role: string }; const product = getProductBySlug(db, slug); if (!product) return reply.code(404).send({ error: "Nie znaleziono produktu." }); const shotRole = ShotRole.safeParse(role); if (!shotRole.success || !STUDIO_ROLES.includes(shotRole.data)) return reply.code(422).send({ error: "Ta rola nie należy do planu kadrów." }); const plan = planStudioShot(db, product.id, shotRole.data); if (plan.paid) { if (!cfg.runcomfyApiKey) return reply.code(503).send({ error: "Brak RUNCOMFY_API_KEY — provider AI nieaktywny." }); const quota = paidQuota(db); if (quota.used >= quota.limit) return reply.code(429).send({ error: `Dzienny limit płatnych zleceń (${quota.limit}) wyczerpany.` }); } try { const { job } = enqueueStudioShot(db, { productId: product.id, role: shotRole.data, actor: req.user!.id }); return { job, plan }; } catch (err) { return reply.code(422).send({ error: err instanceof StudioError ? err.message : "Nie zlecono ujęcia." }); } }); // Wycofanie masterów (bez kasowania plików i historii): decyzja „odrzucone” // z notatką. Zależne warianty tracą aktualny master → mediaReview je blokuje. app.post("/api/products/:slug/studio/retire", async (req, reply) => { if (!requireUser(req, reply)) return; if (req.user!.role === "operator") return reply.code(403).send({ error: "Wycofuje rola approver/admin." }); const product = getProductBySlug(db, (req.params as { slug: string }).slug); if (!product) return reply.code(404).send({ error: "Nie znaleziono produktu." }); const body = z.object({ roles: z.array(ShotRole).min(1).max(12), note: z.string().min(3).max(500), keepEngine: z.string().optional() }).parse(req.body); const rows = db.prepare( `SELECT id, role, sha256 FROM assets WHERE product_id = ? AND variant_id IS NULL AND status IN ('approved','candidate') AND role IN (${body.roles.map(() => "?").join(",")}) AND COALESCE(json_extract(provenance_json, '$.engine'), '') <> ?` ).all(product.id, ...body.roles, body.keepEngine ?? STUDIO_ENGINE) as unknown as Array<{ id: string; role: string; sha256: string }>; const decisions = decideBatch(db, rows.map((a) => ({ productId: product.id, scope: "shot" as const, target: a.role, assetId: a.id, variantId: null, decision: "rejected" as const, decidedBy: req.user!.id, note: body.note, expectedSha256: a.sha256, })), () => {}); audit(db, { actor: req.user!.id, action: "studio.retire_masters", entity: "products", entityId: product.id, detail: { roles: body.roles, count: decisions.length, note: body.note } }); return { retired: decisions.length }; }); app.put("/api/products/:slug/studio/material-link", async (req, reply) => { if (!requireUser(req, reply)) return; if (req.user!.role === "operator") return reply.code(403).send({ error: "Zmienia rola approver/admin." }); const product = getProductBySlug(db, (req.params as { slug: string }).slug); if (!product) return reply.code(404).send({ error: "Nie znaleziono produktu." }); const body = z.object({ baseSlug: z.string().min(1).nullable() }).parse(req.body); const base = body.baseSlug ? getProductBySlug(db, body.baseSlug) : null; if (body.baseSlug && !base) return reply.code(404).send({ error: "Nie znaleziono produktu bazowego." }); try { setMaterialLink(db, product.id, base?.id ?? null, req.user!.id); } catch (err) { return reply.code(422).send({ error: err instanceof Error ? err.message : "Błąd powiązania." }); } return { ok: true }; }); app.put("/api/sources/:id/studio-exclusion", async (req, reply) => { if (!requireUser(req, reply)) return; const { id } = req.params as { id: string }; if (!db.prepare("SELECT 1 AS x FROM source_assets WHERE id = ?").get(id)) return reply.code(404).send({ error: "Brak materiału." }); const body = z.object({ spec: z.string().max(2000).nullable(), note: z.string().max(500).optional() }).parse(req.body); try { setExclusion(db, id, body.spec, req.user!.id, body.note); } catch (err) { return reply.code(422).send({ error: err instanceof Error ? err.message : "Błąd zapisu." }); } return { ok: true }; }); // ---------- blueprint (Etap C): svg | hybrid | ai ---------- app.post("/api/products/:slug/blueprint/generate", async (req, reply) => { if (!requireUser(req, reply)) return; const product = getProductBySlug(db, (req.params as { slug: string }).slug); if (!product) return reply.code(404).send({ error: "Nie znaleziono produktu." }); const body = z .object({ mode: z.enum(["svg", "hybrid", "ai", "composed"]), sourceIds: z.array(z.string()).max(4).optional(), refineView: z.enum(["top", "front", "sleep"]).optional(), baseJobId: z.string().optional(), // ręczny override referencji per widok (src_* lub ast_* tego produktu) viewRefs: z.record(z.enum(["top", "front", "sleep"]), z.array(z.string()).min(1).max(8)).optional(), }) .parse(req.body); const dims = confirmedDims(db, product.id); const views = planBlueprintViews(dims); // referencja musi istnieć i należeć do produktu (źródło albo asset galerii) const refBelongs = (id: string) => Boolean( db .prepare( `SELECT 1 AS x FROM source_assets sa WHERE sa.id = ? AND (sa.product_id = ? OR EXISTS (SELECT 1 FROM source_asset_products l WHERE l.source_id = sa.id AND l.product_id = ?)) UNION SELECT 1 FROM assets WHERE id = ? AND product_id = ?` ) .get(id, product.id, product.id, id, product.id) ); if (body.mode === "svg") { // darmowy, deterministyczny — wymiary wyłącznie z faktów confirmed const fabric = db .prepare( `SELECT f.name FROM variants v JOIN fabrics f ON f.id = v.fabric_id WHERE v.product_id = ? LIMIT 1` ) .get(product.id) as unknown as { name: string } | undefined; const shade = db .prepare( "SELECT value_json FROM product_facts WHERE product_id = ? AND key = 'fabric.photographed_shade'" ) .get(product.id) as unknown as { value_json: string } | undefined; const shadeCode = shade ? String(JSON.parse(shade.value_json)) : ""; const svg = buildBlueprintSvg(dims, { productName: product.name, fabricLabel: [fabric?.name, shadeCode].filter(Boolean).join(" ") || "—", date: new Date().toISOString().slice(0, 10), }); const buf = await sharp(Buffer.from(svg)).png().toBuffer(); const sha = crypto.createHash("sha256").update(buf).digest("hex"); const meta = await sharp(buf).metadata(); const rel = path.join("assets", sha.slice(0, 2), `${sha}.png`); const abs = path.join(cfg.mediaDir, rel); fs.mkdirSync(path.dirname(abs), { recursive: true }); fs.writeFileSync(abs, buf, { flag: "wx" }); const asset = registerAsset(db, { productId: product.id, kind: "image", role: "blueprint", sha256: sha, mime: "image/png", bytes: buf.length, width: meta.width, height: meta.height, path: rel, origin: "derived", provenance: { mode: "svg", dimsSource: "product_facts.confirmed", dims }, note: "Deterministyczny rysunek wymiarowy z faktów confirmed.", }); audit(db, { actor: req.user!.id, action: "blueprint.generate", entity: "assets", entityId: asset.id, detail: { productId: product.id, mode: "svg" }, }); return { asset }; } // tryby płatne — ten sam limit dzienny co ujęcia foto if (!cfg.runcomfyApiKey) { return reply.code(503).send({ error: "Brak RUNCOMFY_API_KEY — provider AI nieaktywny." }); } const todayStart = new Date(); todayStart.setHours(0, 0, 0, 0); const usedToday = ( db .prepare( "SELECT COUNT(*) AS c FROM jobs WHERE provider = 'runcomfy' AND provider_request_id IS NOT NULL AND created_at >= ?" ) .get(todayStart.toISOString()) as unknown as { c: number } ).c; const dailyLimit = Number(process.env.VILMAL_MAX_PAID_JOBS_PER_DAY ?? 10); // Tryb composed: widoki z planu faktów; referencje regułowe // (zatwierdzona galeria + pasujące źródła) albo override z requestu. if (body.mode === "composed") { if (body.refineView && !body.baseJobId) { return reply.code(422).send({ error: "refineView wymaga baseJobId (zadanie źródłowe do przeniesienia pozostałych widoków)." }); } if (body.refineView && !views.includes(body.refineView)) { return reply.code(422).send({ error: `Widok „${body.refineView}" nie wchodzi w plan rysunku dla tego produktu.` }); } const viewRefs: Partial> = {}; for (const v of views) { const ids = body.viewRefs?.[v] ?? resolveViewRefs(db, product.id, v); const bad = ids.filter((id) => !refBelongs(id)); if (bad.length > 0) { return reply.code(422).send({ error: `Widok ${v}: referencje nie istnieją lub nie należą do produktu (${bad.join(", ")}).` }); } viewRefs[v] = ids; } // baseJobId: przenosi viewRequestIds z poprzedniego zlecenia. // Z refineView — regeneruje tylko ten widok; bez niego — czysta rekompozycja (koszt 0). let viewRequestIds: Record | undefined; if (body.baseJobId) { const base = getJob(db, body.baseJobId); if (!base) return reply.code(404).send({ error: "Brak zadania bazowego." }); const basePayload = JSON.parse(base.payload_json) as { viewRequestIds?: Record; }; viewRequestIds = Object.fromEntries( Object.entries(basePayload.viewRequestIds ?? {}).filter(([v]) => v !== body.refineView) ); } const reused = viewRequestIds ? Object.keys(viewRequestIds).length : 0; const cost = views.length - reused; if (cost > 0 && usedToday + cost > dailyLimit) { return reply.code(429).send({ error: `Dzienny limit płatnych zleceń (${dailyLimit}) wyczerpany.` }); } const job = enqueueJob(db, { type: "generate_blueprint", provider: "runcomfy", idempotencyKey: `bp:${product.id}:composed:${Date.now()}`, payload: { productId: product.id, role: "blueprint", blueprintMode: "composed", viewRefs, ...(viewRequestIds ? { viewRequestIds } : {}), }, }); audit(db, { actor: req.user!.id, action: "blueprint.generate.enqueue", entity: "jobs", entityId: job.id, detail: { productId: product.id, mode: "composed", viewRefs }, }); return { job }; } if (usedToday + 1 > dailyLimit) { return reply.code(429).send({ error: `Dzienny limit płatnych zleceń (${dailyLimit}) wyczerpany.` }); } // hybrid/ai: referencje regułowe — ai ma własne (perspektywa 3/4 jak // docelowa ilustracja), hybrid bazuje na widoku front. const sourceIds = body.sourceIds ?? (body.mode === "ai" ? resolveBlueprintAiRefs(db, product.id) : resolveViewRefs(db, product.id, "front").slice(0, 4)); const bad = sourceIds.filter((id) => !refBelongs(id)); if (bad.length > 0) { return reply.code(422).send({ error: "Część referencji nie istnieje lub nie należy do produktu." }); } const promptCtx = blueprintPromptCtx(db, product.id, dims); const job = enqueueJob(db, { type: "generate_blueprint", provider: "runcomfy", idempotencyKey: `bp:${product.id}:${body.mode}:${sourceIds.sort().join(",")}:${Date.now()}`, payload: { productId: product.id, role: "blueprint", sourceIds, blueprintMode: body.mode, ...(body.mode === "ai" ? { quality: "high" } : {}), prompt: body.mode === "hybrid" ? buildBlueprintHybridPrompt(promptCtx) : buildBlueprintAiPrompt(dims, promptCtx), }, }); audit(db, { actor: req.user!.id, action: "blueprint.generate.enqueue", entity: "jobs", entityId: job.id, detail: { productId: product.id, mode: body.mode, sourceIds }, }); return { job }; }); // ---------- dokumenty (Etap C): karta produktu PDF — lokalna generacja ---------- app.post("/api/products/:slug/documents/product-card", async (req, reply) => { if (!requireUser(req, reply)) return; const product = getProductBySlug(db, (req.params as { slug: string }).slug); if (!product) return reply.code(404).send({ error: "Nie znaleziono produktu." }); const out = await buildProductCard(db, cfg, product.id); if ("missing" in out) { return reply.code(422).send({ error: `Karta produktu — braki: ${out.missing.join("; ")}.`, missing: out.missing, }); } audit(db, { actor: req.user!.id, action: "document.product_card.generate", entity: "assets", entityId: out.asset.id, detail: { productId: product.id, version: out.asset.version, sha256: out.asset.sha256 }, }); return { asset: out.asset }; }); // ---------- recolor wariantu (Etap D) — darmowy, synchroniczny ---------- app.post("/api/products/:slug/variants/:variantId/recolor", async (req, reply) => { if (!requireUser(req, reply)) return; const { slug, variantId } = req.params as { slug: string; variantId: string }; const product = getProductBySlug(db, slug); if (!product) return reply.code(404).send({ error: "Nie znaleziono produktu." }); const body = z.object({ role: ShotRole.optional() }).parse(req.body ?? {}); const role = body.role ?? "packshot_34"; if (!GENERATABLE_ROLES.has(role)) { return reply.code(422).send({ error: `Rola „${role}" nie jest recolorowalna.` }); } const out = await buildRecolor(db, cfg, { productId: product.id, variantId, role, requireMask: true }); if ("missing" in out) { return reply.code(422).send({ error: `Recolor — braki: ${out.missing.join("; ")}.`, missing: out.missing, }); } return { asset: out.asset, cached: out.cached }; }); // Wsad recolorów przez kolejkę — ciężka praca CPU nie może iść // synchronicznie przez HTTP po jednym ujęciu (zamrażało maszynę). // Worker grupuje po roli: master+maska dekodowane raz na rolę, // postęp w result_json zadania (poll GET /api/jobs/:id). app.post("/api/products/:slug/variants/recolor-batch", async (req, reply) => { if (!requireUser(req, reply)) return; const product = getProductBySlug(db, (req.params as { slug: string }).slug); if (!product) return reply.code(404).send({ error: "Nie znaleziono produktu." }); const body = z .object({ tasks: z .array(z.object({ variantId: z.string().min(1), role: ShotRole })) .min(1) .max(600), }) .parse(req.body ?? {}); for (const t of body.tasks) { if (!GENERATABLE_ROLES.has(t.role)) { return reply.code(422).send({ error: `Rola „${t.role}" nie jest recolorowalna.` }); } } const ids = body.tasks.map((t) => t.variantId); const found = new Set( ( db .prepare( `SELECT id FROM variants WHERE product_id = ? AND id IN (${ids.map(() => "?").join(",")})` ) .all(product.id, ...ids) as Array<{ id: string }> ).map((r) => r.id) ); const bad = ids.filter((id) => !found.has(id)); if (bad.length) { return reply.code(422).send({ error: `Warianty nie należą do produktu: ${bad.join(", ")}` }); } // deduplikacja zleceń po (variantId, role) — UI może wysłać powtórki const seen = new Set(); const tasks = body.tasks.filter((t) => { const k = `${t.variantId}|${t.role}`; if (seen.has(k)) return false; seen.add(k); return true; }); // Warianty powstają tylko z mastera z zatwierdzoną maską — ciężka // segmentacja nie może wystartować po cichu w środku wsadu. const maskState = new Map(recolorReadiness(db, cfg, product.id).roles.map((r) => [r.role as string, r.mask])); const blockedRoles = [...new Set(tasks.map((t) => t.role))].filter((r) => maskState.get(r) !== "confirmed"); if (blockedRoles.length) { return reply.code(422).send({ error: `Maska mastera nie jest zatwierdzona dla: ${blockedRoles.join(", ")}. Wypiecz ją i zatwierdź w Studio Wariantów.`, roles: blockedRoles, }); } // Jeden aktywny wsad na produkt: ponowne kliknięcie/odświeżenie strony // nie tworzy duplikatu, tylko zwraca trwające zadanie. const active = activeProductJob(db, "recolor_batch", product.id); if (active) return { job: active, alreadyRunning: true }; const job = enqueueJob(db, { type: "recolor_batch", payload: { productId: product.id, tasks, actor: req.user!.id }, }); audit(db, { actor: req.user!.id, action: "variant.recolor_batch.enqueue", entity: "jobs", entityId: job.id, detail: { productId: product.id, total: tasks.length }, }); return { job, alreadyRunning: false }; }); // Gotowość generowania wariantów: warunki z powodami i akcjami naprawczymi. app.get("/api/products/:slug/variants/readiness", async (req, reply) => { if (!requireUser(req, reply)) return; const product = getProductBySlug(db, (req.params as { slug: string }).slug); if (!product) return reply.code(404).send({ error: "Nie znaleziono produktu." }); reply.header("Cache-Control", "private, no-store"); return recolorReadiness(db, cfg, product.id); }); // Ostatnie zadanie wsadu (aktywne albo zakończone) — odtwarza postęp po odświeżeniu strony. app.get("/api/products/:slug/variants/recolor-job", async (req, reply) => { if (!requireUser(req, reply)) return; const product = getProductBySlug(db, (req.params as { slug: string }).slug); if (!product) return reply.code(404).send({ error: "Nie znaleziono produktu." }); reply.header("Cache-Control", "private, no-store"); const row = latestProductJob(db, "recolor_batch", product.id); if (!row) return { job: null }; const result = row.result_json ? (JSON.parse(row.result_json) as { done?: number; total?: number; created?: number; cached?: number; rejected?: number; failed?: number; failures?: Array<{ variantId: string; role: string; reason: string }> }) : null; const payload = JSON.parse(row.payload_json) as { tasks?: unknown[] }; return { job: { id: row.id, status: row.status, error: row.error, createdAt: row.created_at, updatedAt: row.updated_at, done: result?.done ?? 0, total: result?.total ?? payload.tasks?.length ?? 0, created: result?.created ?? 0, cached: result?.cached ?? 0, rejected: result?.rejected ?? 0, failed: result?.failed ?? 0, failures: result?.failures ?? [], }, }; }); // Maska mastera: wypiekanie (zadanie w kolejce, lokalnie) i podgląd nakładki. app.post("/api/products/:slug/masters/:role/mask", async (req, reply) => { if (!requireUser(req, reply)) return; const { slug, role } = req.params as { slug: string; role: string }; const product = getProductBySlug(db, slug); if (!product) return reply.code(404).send({ error: "Nie znaleziono produktu." }); const shotRole = ShotRole.safeParse(role); if (!shotRole.success || !GENERATABLE_ROLES.has(shotRole.data)) return reply.code(422).send({ error: "Ta rola nie ma maski recoloru." }); if (!approvedAssetForRole(db, product.id, shotRole.data, null)) return reply.code(422).send({ error: `Brak zatwierdzonego mastera „${shotRole.data}".` }); const body = z.object({ force: z.boolean().optional() }).parse(req.body ?? {}); const active = activeProductJob(db, "bake_mask", product.id, shotRole.data); if (active) return { job: active, alreadyRunning: true }; const job = enqueueJob(db, { type: "bake_mask", payload: { productId: product.id, role: shotRole.data, ...(body.force ? { force: true } : {}) } }); audit(db, { actor: req.user!.id, action: "master.mask_bake.enqueue", entity: "jobs", entityId: job.id, detail: { productId: product.id, role: shotRole.data, force: Boolean(body.force) } }); return { job, alreadyRunning: false }; }); app.post("/api/products/:slug/masters/:role/mask/confirm", async (req, reply) => { if (!requireUser(req, reply)) return; const { slug, role } = req.params as { slug: string; role: string }; const product = getProductBySlug(db, slug); if (!product) return reply.code(404).send({ error: "Nie znaleziono produktu." }); const shotRole = ShotRole.safeParse(role); if (!shotRole.success || !GENERATABLE_ROLES.has(shotRole.data)) return reply.code(422).send({ error: "Ta rola nie ma maski recoloru." }); const out = confirmMasterMask(db, cfg, { productId: product.id, role: shotRole.data, actor: req.user!.id }); if ("error" in out) return reply.code(409).send({ error: out.error }); return { ok: true, maskKey: out.maskKey }; }); app.get("/api/products/:slug/masters/:role/mask-preview", async (req, reply) => { if (!requireUser(req, reply)) return; const { slug, role } = req.params as { slug: string; role: string }; const product = getProductBySlug(db, slug); if (!product) return reply.code(404).send({ error: "Nie znaleziono produktu." }); const shotRole = ShotRole.safeParse(role); if (!shotRole.success || !GENERATABLE_ROLES.has(shotRole.data)) return reply.code(422).send({ error: "Ta rola nie ma maski recoloru." }); const out = await maskOverlayPng(db, cfg, { productId: product.id, role: shotRole.data }); if ("error" in out) return reply.code(out.code).send({ error: out.error }); reply.header("Cache-Control", "private, no-store").header("Content-Type", "image/png").header("X-Mask-Coverage", out.coverage.toFixed(4)); return reply.send(out.png); }); // ---------- zadania ---------- app.get("/api/jobs", async (req, reply) => { if (!requireUser(req, reply)) return; reply.header("Cache-Control", "private, no-store"); return { jobs: listJobs(db, 200).map((j) => ({ id: j.id, type: j.type, status: j.status, provider: j.provider, attempts: j.attempts, maxAttempts: j.max_attempts, error: j.error, result: j.result_json ? JSON.parse(j.result_json) : null, createdAt: j.created_at, updatedAt: j.updated_at, runAfter: j.run_after, })) }; }); app.get("/api/jobs/:id", async (req, reply) => { if (!requireUser(req, reply)) return; const job = getJob(db, (req.params as { id: string }).id); if (!job) return reply.code(404).send({ error: "Brak zadania." }); reply.header("Cache-Control", "private, no-store"); return { job, progress: jobProgress(db, job) }; }); // ---------- health ---------- app.get("/api/health", async () => ({ ok: true, env: cfg.env, providerConfigured: Boolean(cfg.runcomfyApiKey), })); }