import { z } from "zod"; import { type Db, nowIso, tx } from "../db.js"; import { newId } from "./ids.js"; import { audit } from "./audit.js"; import { getFacts } from "./facts.js"; import { listVariants, listShades, parseFabricParams, type FabricRow } from "./fabric.js"; import { approvedAssetForRole } from "./assets.js"; import { contentHash, OFFER_ROLES } from "./mediaReview.js"; import { offerProfile, skuFromPattern } from "./productProfile.js"; import { baseColorForShade } from "./baseColor.js"; export const CONTENT_RULES = [ { channel: "Allegro", checkedAt: "2026-09-16", url: "https://help.allegro.com/pl/sell/c/zasady-dla-tytulu-i-opisu", rule: "Tytuł 12–75 znaków, minimum 3 słowa; opis dotyczy produktu, bez kontaktów, obcych linków i obietnic." }, { channel: "Allegro zdjęcia", checkedAt: "2026-09-16", url: "https://help.allegro.com/pl/sell/c/zasady-dla-zdjec", rule: "Oznaczenia AI; infografiki zależą od dostępu BrandHub. Integracja docelowego konta do potwierdzenia." }, { channel: "Google", checkedAt: "2026-09-16", url: "https://support.google.com/merchants/answer/14743464?hl=en", rule: "Tekst AI: structured_title/structured_description i digital_source_type. Zachowaj oznaczenia pochodzenia obrazów. Obsługa przez integrację pozostaje do potwierdzenia." }, ]; export interface ContentSection { heading: string; paragraphs: string[] } export interface ChannelContent { title: string; shortDescription: string; sections: ContentSection[]; extras: Record; } export function buildOfferContent(db: Db, productId: string) { const product = db.prepare("SELECT id,slug,name FROM products WHERE id = ?").get(productId) as { id: string; slug: string; name: string } | undefined; if (!product) throw new Error("Brak produktu dla treści ofertowej."); const kind = (db.prepare("SELECT kind FROM products WHERE id=?").get(productId) as { kind: string }).kind; const profile = offerProfile(product, kind); const facts = getFacts(db, productId); const confirmed = facts.filter((f) => f.status === "confirmed" && !!f.sourceRef); const value = (key: string) => confirmed.find((f) => f.key === key)?.value; const number = (key: string) => { const v = value(key); return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : null; }; const text = (key: string) => { const v = value(key); return typeof v === "string" && v.trim() ? v : null; }; const cm = (v: number) => String(v).replace(".", ","); const width = number("dimensions.width_cm"), depth = number("dimensions.depth_cm"), height = number("dimensions.height_with_cushions_cm"); const sleepW = number("sleeping.width_cm"), sleepL = number("sleeping.length_cm"); const storage = value("features.bedding_container") === true; const sleeping = sleepW !== null && sleepL !== null; const shared: ContentSection[] = []; const parameters: Record = {}; if (width && depth && height) { shared.push({ heading: "Zaplanuj miejsce w salonie", paragraphs: [`Wymiary ${profile.modelGen} to ${cm(width)} × ${cm(depth)} × ${cm(height)} cm (szerokość × głębokość × wysokość z poduszkami). Przed zakupem sprawdź miejsce ustawienia i drogę wniesienia mebla.`] }); parameters["Szerokość (cm)"] = width; parameters["Głębokość (cm)"] = depth; parameters["Wysokość z poduszkami (cm)"] = height; } if (sleeping) { shared.push({ heading: "Salon także na noc", paragraphs: [`Po rozłożeniu otrzymujesz powierzchnię spania ${cm(sleepW!)} × ${cm(sleepL!)} cm. Funkcja spania pozwala wykorzystać ten sam mebel do wypoczynku w dzień i odpoczynku nocą.`] }); parameters["Powierzchnia spania (cm)"] = `${cm(sleepW!)} × ${cm(sleepL!)}`; } if (value("mechanism.sleep") === "seat_pullout_backfold") shared.push({ heading: "Jak działa rozkładanie", paragraphs: ["Siedzisko wysuwa się do przodu, a jego oparcie składa się do pozycji leżącej. Oparcie szezlonga pozostaje pionowo."] }); if (storage) { shared.push({ heading: "Miejsce na pościel", paragraphs: ["Pojemnik daje dodatkowe miejsce na pościel. Dzięki niemu tekstylia potrzebne do przygotowania spania możesz przechowywać przy narożniku."] }); parameters["Pojemnik na pościel"] = true; } const cushions = number("cushions.loose_back_count"); if (cushions) { shared.push({ heading: "Poduszki w zestawie", paragraphs: [`Zestaw zawiera ${cushions} luźnych poduszek oparciowych. Elementy aranżacji widoczne na zdjęciach nie określają zawartości zestawu.`] }); parameters["Liczba luźnych poduszek oparciowych"] = cushions; } const seat = [["dimensions.body_depth_cm", "Głębokość korpusu bez szezlonga"], ["dimensions.seat_height_cm", "Wysokość siedziska"], ["dimensions.seat_depth_cm", "Głębokość siedziska"], ["dimensions.height_without_cushions_cm", "Wysokość bez poduszek"], ["dimensions.armrest_width_cm", "Szerokość podłokietnika"]] as const; const seatLines = seat.flatMap(([key, label]) => { const n = number(key); if (!n) return []; parameters[`${label} (cm)`] = n; return [`${label}: ${cm(n)} cm.`]; }); if (seatLines.length) shared.push({ heading: "Wymiary do porównania", paragraphs: seatLines }); const assembly = text("assembly.by_customer"); const legs = number("legs.height_cm"); if (assembly) shared.push({ heading: "Zestaw i montaż", paragraphs: [`Montaż po stronie klienta: ${assembly}.${legs && value("legs.included") === true ? ` Nóżki o wysokości ${cm(legs)} cm są dołączone do zestawu.` : ""}`] }); const packs = value("packages.items"); if (Array.isArray(packs) && packs.length > 0 && packs.every((p) => p && Array.isArray(p.dimensions_cm) && p.dimensions_cm.length === 3 && p.dimensions_cm.every((n: unknown) => typeof n === "number" && n > 0) && typeof p.weight_kg === "number" && p.weight_kg > 0)) { shared.push({ heading: "Przygotuj się na dostawę", paragraphs: [`Mebel jest pakowany w ${packs.length} paczki: ${packs.map((p) => `${p.dimensions_cm.map(cm).join(" × ")} cm, ${p.weight_kg} kg`).join("; ")}. Sprawdź szerokość drzwi i przejść na trasie wniesienia.`] }); } const country = text("production.country"), warranty = number("warranty.months"); if (country || warranty) shared.push({ heading: "Produkcja i gwarancja", paragraphs: [country ? `Kraj produkcji: ${country}.` : "", warranty ? `Gwarancja: ${warranty} miesiące. Szczegółowe warunki określa dokument gwarancyjny.` : ""].filter(Boolean) }); const care = text("care.instructions"); if (care) shared.push({ heading: "Pielęgnacja", paragraphs: [care] }); const evidenceKeys = ["gpsr.manufacturer", "gpsr.responsible_person", "gpsr.safety_information", "warranty.months"] as const; const variantList = listVariants(db, productId); const hasCornerSides = variantList.some((v) => v.side !== "S"); const commonBlockers = [ ...(!care ? ["Brak potwierdzonej instrukcji pielęgnacji; nie dodano metody czyszczenia."] : []), ...(evidenceKeys.every((k) => value(k) !== undefined && value(k) !== null) ? [] : ["Pełne dane producenta/GPSR, źródła bezpieczeństwa i warunki gwarancji wymagają sprawdzenia."]), ...(hasCornerSides ? ["Konwencja stron L/P i zgodność z galerią wymagają przeglądu człowieka."] : []), ...(text("base.allegro_category_id") ? [] : ["Identyfikatory parametrów kategorii docelowej wymagają potwierdzenia w kanale."]), ...(text("commerce.ean") || text("commerce.ean_exception") ? [] : ["EAN-y wariantów lub dopuszczalny wyjątek wymagają potwierdzenia w kanale."]), ]; const variants = variantList.map((variant) => { const fabric = db.prepare("SELECT * FROM fabrics WHERE id = ?").get(variant.fabric_id) as unknown as FabricRow; const shade = listShades(db, variant.fabric_id).find((s) => s.code === variant.shade_code); const shadeName = shade?.verification === "confirmed" ? shade.name : null; const side = variant.side === "L" ? "lewy" : variant.side === "P" ? "prawy" : null; const fabricLabel = `${fabric.name} ${variant.shade_code}${shadeName ? ` ${shadeName}` : ""}`; const name = [profile.kindLabel, profile.model, side, fabricLabel].filter(Boolean).join(" "); const pattern = text("commerce.sku_pattern"); const sku = skuFromPattern(pattern, variant.side, variant.shade_code); const lead = side ? `${profile.model} w wersji ${variant.side === "L" ? "lewej" : "prawej"}, w tkaninie ${fabricLabel}.${sleeping ? ` ${profile.kindLabel} z funkcją spania` : ` ${profile.kindLabel} do salonu`}${storage ? " i pojemnikiem na pościel" : ""} — wybierz układ i odcień pasujące do Twojego wnętrza.` : `${profile.model} w tkaninie ${fabricLabel}.${sleeping ? ` ${profile.kindLabel} z funkcją spania` : ` ${profile.kindLabel} do salonu`}${storage ? " i pojemnikiem na pościel" : ""} — wybierz odcień pasujący do Twojego wnętrza.`; const fabricParagraphs = [`Tapicerka: ${fabricLabel}, producent tkaniny: ${fabric.producer}.`]; if (fabric.verification === "confirmed" && fabric.card_path) { const params = parseFabricParams(fabric.params_json); if (typeof params.composition === "string") fabricParagraphs.push(`Skład: ${params.composition}.`); const g = params.grammage_g_m2 as { value?: unknown; tolerance?: unknown } | undefined; if (typeof g?.value === "number" && typeof g.tolerance === "string") fabricParagraphs.push(`Gramatura: ${g.value} g/m² ${g.tolerance}.`); const m = params.martindale_cycles as { min?: unknown; max?: unknown } | undefined; if (typeof m?.min === "number" && typeof m.max === "number" && m.min <= m.max) { const range = m.min === m.max ? m.min.toLocaleString("pl-PL") : `${m.min.toLocaleString("pl-PL")}–${m.max.toLocaleString("pl-PL")}`; fabricParagraphs.push(`Wynik badania Martindale tkaniny: ${range} cykli. Parametr dotyczy materiału, nie gwarantowanego czasu użytkowania mebla.`); } } fabricParagraphs.push("Odcień na ekranie zależy od ustawień wyświetlacza i światła. Przy wyborze koloru porównaj fizyczną próbkę tkaniny."); const faq: ContentSection = { heading: "Pytania przed zakupem", paragraphs: [ ...(side ? [`Którą stronę wybieram? Ten wariant jest oznaczony jako ${side}. Porównaj układ szezlonga ze zdjęciami tej konkretnej wersji.`] : []), ...(sleeping ? [`Jaką powierzchnię spania otrzymam? ${cm(sleepW!)} × ${cm(sleepL!)} cm po rozłożeniu.`] : []), ...(storage ? ["Czy jest miejsce na pościel? Tak, narożnik ma pojemnik na pościel."] : []), ...(assembly ? [`Czy montaż jest po mojej stronie? ${assembly}.`] : []), ] }; const sections = [{ heading: name, paragraphs: [lead] }, ...shared, { heading: "Tkanina i kolor Twojego wariantu", paragraphs: fabricParagraphs }, faq]; let allegroTitle = `${name}${sleeping ? " rozkładany" : ""}`; if (allegroTitle.length > 75) allegroTitle = [profile.kindLabel, profile.model, side, fabric.name, variant.shade_code].filter(Boolean).join(" ") + (sleeping ? " rozkładany" : ""); const short = `${name}.${width && depth ? ` Wymiary ${width} × ${depth} cm.` : ""}${sleeping ? ` Spanie ${sleepW} × ${sleepL} cm.` : ""}${storage ? " Pojemnik na pościel." : ""}`; const gallery = OFFER_ROLES.flatMap((role) => { const a = approvedAssetForRole(db, productId, role, variant.id); return a ? [{ role, id: a.id, sha256: a.sha256, alt: `${name} — ${{ hero: "aranżacja", packshot_front: "widok z przodu", packshot_34: "widok 3/4", side: "widok z boku", back: "widok z tyłu", sleep: "funkcja spania", storage: "pojemnik na pościel", detail: "detal tkaniny" }[role]}` }] : []; }); const plain = sections.map((s) => `${s.heading}\n${s.paragraphs.join("\n")}`).join("\n\n"); const channels: Record = { main: { title: name, shortDescription: lead, sections, extras: {} }, allegro: { title: allegroTitle, shortDescription: short, sections: [{ heading: "Wybrany wariant", paragraphs: [short] }, ...shared.filter((s) => s.heading !== "Produkcja i gwarancja"), { heading: "Tapicerka", paragraphs: fabricParagraphs }, faq], extras: { parameters: { ...parameters, ...(side ? { Strona: side } : {}), Tkanina: fabric.name, "Kod odcienia": variant.shade_code, "Odcień tkaniny": `${fabric.name} ${variant.shade_code}`, Kolor: baseColorForShade({ name: shade?.name ?? null, color_hex: shade?.color_hex ?? null, code: variant.shade_code }) }, parameterMappingStatus: "Wymaga identyfikatorów parametrów kategorii docelowej. Gwarancja i dane sprzedawcy do właściwych sekcji Allegro, nie do opisu." } }, woocommerce: { title: name, shortDescription: short, sections, extras: { seoTitle: name, metaDescription: short, imageAlt: gallery.map(({ id, alt }) => ({ id, alt })), integrationStatus: "Propozycja treści; nie utworzono sklepu ani feedu." } }, google: { title: name, shortDescription: short, sections: [{ heading: "Opis produktu", paragraphs: [plain] }], extras: { structured_title: { digital_source_type: "trained_algorithmic_media", content: name }, structured_description: { digital_source_type: "trained_algorithmic_media", content: plain }, integrationStatus: "Wymaga mapowania w integracji. Nie ustawiono identifier_exists=false ani zapasów/URL bez dowodu." } }, meta: { title: `${profile.model} · ${fabricLabel}`, shortDescription: `Twój salon, Twój odcień. ${short}`, sections: [{ heading: "Wariant tekstu reklamy", paragraphs: [`${sleeping ? "Salon w dzień, miejsce do spania wieczorem. " : "Miejsce na codzienny odpoczynek. "}${name}${storage ? " z pojemnikiem na pościel" : ""}. Porównaj wybrany układ i kolor.`] }], extras: { headline: `${[profile.model, side].filter(Boolean).join(" ")} · ${fabric.name} ${variant.shade_code}`, cta: "Sprawdź wariant", publicationStatus: "Projekt — nie uruchomiono kampanii." } }, }; const blockers = [...commonBlockers, ...(gallery.length !== 8 ? [`Galeria: ${gallery.length}/8 zatwierdzonych ról tego wariantu; żadnych zdjęć innego koloru/strony w zastępstwie.`] : []), ...(!sku ? ["Brak potwierdzonego schematu SKU."] : []), ...(!shadeName ? ["Nazwa odcienia niepotwierdzona."] : [])]; if (allegroTitle.length > 75 || allegroTitle.length < 12 || allegroTitle.split(/\s+/).length < 3) blockers.push("Tytuł Allegro nie spełnia limitów."); return { id: variant.id, side: variant.side, shade: variant.shade_code, name, sku, channels, gallery, blockers }; }); const state = { product, facts: confirmed, recipeVersion: profile.copyVersion, rules: CONTENT_RULES, variants, commerce: { pricePln: number("commerce.price_pln"), vat: text("commerce.vat_rate"), scope: "Do docelowego snapshotu po sprawdzeniu zakresu decyzji właściciela." }, provenance: "Projekty redakcyjne przygotowane z pomocą AI; dane i parametry pochodzą z potwierdzonych rekordów. Nie wykonano inferencji zewnętrznej." }; return { ...state, draftSha256: contentHash(state), status: "draft" as const, publishable: false as const }; } export const CONTENT_CHANNELS = ["main", "allegro", "woocommerce", "google", "meta"] as const; const shaSchema = z.string().regex(/^[a-f0-9]{64}$/); const copySchema = z.object({ title: z.string().trim().min(1).max(200), shortDescription: z.string().trim().min(1).max(2000), sections: z.array(z.object({ heading: z.string().trim().min(1).max(200), paragraphs: z.array(z.string().trim().min(1).max(12000)).min(1).max(20) })).min(1).max(30), }); export const saveContentSchema = z.object({ expectedDraftSha256: shaSchema, baseRevisionId: z.string().min(1).optional(), baseRevisionSha256: shaSchema.optional(), edits: z.array(z.object({ variantId: z.string().min(1), channel: z.enum(CONTENT_CHANNELS), content: copySchema })).max(170), note: z.string().trim().min(1).max(2000), }).refine((v) => !!v.baseRevisionId === !!v.baseRevisionSha256, "Wersja bazowa wymaga ID i SHA."); export const contentDecisionSchema = z.object({ expectedSha256: shaSchema, decision: z.enum(["approved", "rejected"]), note: z.string().trim().min(1).max(2000) }); export class ContentConflict extends Error {} const sideTerms = (forms: string) => new RegExp(`(^|[^\\p{L}\\p{N}_])(?:${forms})(?=$|[^\\p{L}\\p{N}_])`, "iu"); const SIDE_FORMS = { L: sideTerms("lewy|lewa|lewe|lewego|lewej|lewemu|lewym|lewą|lewych|lewymi|lewo|lewostronn\\p{L}*"), P: sideTerms("prawy|prawa|prawe|prawego|prawej|prawemu|prawym|prawą|prawych|prawymi|prawo|prawostronn\\p{L}*"), } as const; type OfferDraft = ReturnType; type RevisionRow = { id: string; product_id: string; version: number; source_sha256: string; content_sha256: string; content_json: string; created_by: string; note: string; created_at: string }; type ContentDecision = { id: string; content_sha256: string; decision: "approved" | "rejected"; decided_by: string; note: string; created_at: string }; function revisionRow(db: Db, productId: string, id: string): RevisionRow { const row = db.prepare("SELECT * FROM offer_content_revisions WHERE id=? AND product_id=?").get(id, productId) as RevisionRow | undefined; if (!row) throw new ContentConflict("Nie znaleziono wersji treści dla tego produktu."); return row; } function revisionInfo(db: Db, row: RevisionRow, currentSource: string) { const decision = db.prepare("SELECT id,content_sha256,decision,decided_by,note,created_at FROM offer_content_decisions WHERE revision_id=? ORDER BY rowid DESC LIMIT 1").get(row.id) as ContentDecision | undefined; return { id: row.id, version: row.version, sha256: row.content_sha256, sourceSha256: row.source_sha256, current: row.source_sha256 === currentSource, createdBy: row.created_by, createdAt: row.created_at, note: row.note, decision: decision?.content_sha256 === row.content_sha256 ? decision : null }; } export function listContentRevisions(db: Db, productId: string) { const source = buildOfferContent(db, productId).draftSha256; const rows = db.prepare("SELECT id,product_id,version,source_sha256,content_sha256,created_by,note,created_at FROM offer_content_revisions WHERE product_id=? ORDER BY version DESC").all(productId) as RevisionRow[]; return rows.map((row) => revisionInfo(db, row, source)); } export function getContentRevision(db: Db, productId: string, id: string) { const row = revisionRow(db, productId, id); const content = JSON.parse(row.content_json) as OfferDraft; if (contentHash(content) !== row.content_sha256) throw new ContentConflict("Zapisana treść nie odpowiada SHA wersji."); return { ...revisionInfo(db, row, buildOfferContent(db, productId).draftSha256), content }; } function applyContentEdits(db: Db, content: OfferDraft, source: OfferDraft, edits: z.infer["edits"]) { const seen = new Set(); for (const edit of edits) { const key = `${edit.variantId}:${edit.channel}`; if (seen.has(key)) throw new ContentConflict("Jedna wersja może zawierać tylko jedną edycję danego wariantu i kanału."); seen.add(key); const variant = content.variants.find((v) => v.id === edit.variantId); const original = source.variants.find((v) => v.id === edit.variantId); if (!variant || !original) throw new ContentConflict("Edycja wskazuje obcy wariant."); const plain = [edit.content.title, edit.content.shortDescription, ...edit.content.sections.flatMap((s) => [s.heading, ...s.paragraphs])].join("\n"); if (/[<>]/.test(plain)) throw new ContentConflict("Edytor przyjmuje tekst bez HTML."); if (/easy\s*clean|pet\s*friendly|oeko[\s-]*tex|reach|certyfikat|trudnopal|wodoodpor|antyalerg/i.test(plain)) throw new ContentConflict("Twierdzenie materiałowe wymaga osobno zatwierdzonego dowodu, nie samej edycji opisu."); const numbers = (s: string) => (s.replace(/(?<=\d)[ \u00a0\u202f](?=\d{3}(?:\D|$))/g, "").match(/\d+(?:[.,]\d+)?/g) ?? []).map((n) => n.replace(",", ".")); const allowed = new Set(numbers(Object.values(original.channels).flatMap((c) => [c.title, c.shortDescription, ...c.sections.flatMap((s) => [s.heading, ...s.paragraphs])]).join("\n"))); if (numbers(plain).some((n) => !allowed.has(n))) throw new ContentConflict("Nowa liczba nie występuje w źródłowych treściach wariantu. Najpierw potwierdź właściwy fakt."); const profile = offerProfile(content.product); const fabricRow = db .prepare("SELECT f.name FROM variants v JOIN fabrics f ON f.id = v.fabric_id WHERE v.id = ?") .get(variant.id) as { name: string } | undefined; const fabricName = fabricRow?.name ?? ""; const fabricEsc = fabricName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const fabricRe = fabricName ? new RegExp(`${fabricEsc}\\s+(\\d+)`, "gi") : null; const modelOk = profile ? new RegExp(profile.model.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i").test(edit.content.title) : false; if (!modelOk || !fabricRe || !new RegExp(`${fabricEsc}\\s+${variant.shade}(?!\\d)`, "i").test(edit.content.title)) { throw new ContentConflict(`Tytuł musi wskazywać model ${profile?.model ?? "produktu"} i właściwy kod ${fabricName || "tkaniny"}.`); } const wrongSide = variant.side === "L" ? SIDE_FORMS.P : variant.side === "P" ? SIDE_FORMS.L : new RegExp(`(?:${SIDE_FORMS.L.source})|(?:${SIDE_FORMS.P.source})`, "iu"); if ([...plain.matchAll(fabricRe ?? /$^/gi)].some((m) => m[1] !== variant.shade) || wrongSide.test(plain)) throw new ContentConflict("Tekst wskazuje inny odcień lub przeciwną stronę narożnika."); if (edit.channel === "allegro" && (edit.content.title.length < 12 || edit.content.title.length > 75 || edit.content.title.split(/\s+/).length < 3 || /https?:|www\.|@/.test(plain))) throw new ContentConflict("Allegro: sprawdź długość tytułu (12–75), minimum 3 słowa i brak linków/kontaktów."); const extras = { ...variant.channels[edit.channel]!.extras }; if (edit.channel === "google") { extras.structured_title = { digital_source_type: "trained_algorithmic_media", content: edit.content.title }; extras.structured_description = { digital_source_type: "trained_algorithmic_media", content: edit.content.sections.map((s) => `${s.heading}\n${s.paragraphs.join("\n")}`).join("\n\n") }; } if (edit.channel === "woocommerce") { extras.seoTitle = edit.content.title; extras.metaDescription = edit.content.shortDescription; } if (edit.channel === "meta") extras.headline = edit.content.title; variant.channels[edit.channel] = { ...edit.content, extras }; } } export function previewContentRevision(db: Db, productId: string, input: z.input) { const parsed = saveContentSchema.parse(input); const source = buildOfferContent(db, productId); if (source.draftSha256 !== parsed.expectedDraftSha256) throw new ContentConflict("Projekt źródłowy jest nieaktualny. Odśwież fakty i media."); let content = structuredClone(source); if (parsed.baseRevisionId) { const base = getContentRevision(db, productId, parsed.baseRevisionId); if (base.sha256 !== parsed.baseRevisionSha256) throw new ContentConflict("SHA bazowej wersji nie odpowiada podglądowi."); if (!base.current) throw new ContentConflict("Bazowa wersja jest nieaktualna. Rozpocznij nowy projekt z faktów; stara treść pozostaje w historii."); content = structuredClone(base.content); } applyContentEdits(db, content, source, parsed.edits); return { source, content, parsed }; } export function saveContentRevision(db: Db, input: z.input & { productId: string; actor: string }) { return tx(db, () => { const { source, content, parsed } = previewContentRevision(db, input.productId, input); const sha256 = contentHash(content); const existing = db.prepare("SELECT id FROM offer_content_revisions WHERE product_id=? AND source_sha256=? AND content_sha256=?").get(input.productId, source.draftSha256, sha256) as { id: string } | undefined; if (existing) return getContentRevision(db, input.productId, existing.id); const version = Number(db.prepare("SELECT COALESCE(MAX(version),0)+1 AS n FROM offer_content_revisions WHERE product_id=?").get(input.productId)!.n); const id = newId("cnt"); db.prepare("INSERT INTO offer_content_revisions(id,product_id,version,source_sha256,content_sha256,content_json,created_by,note,created_at) VALUES(?,?,?,?,?,?,?,?,?)") .run(id, input.productId, version, source.draftSha256, sha256, JSON.stringify(content), input.actor, parsed.note, nowIso()); audit(db, { actor: input.actor, action: "offer_content.saved", entity: "offer_content_revisions", entityId: id, detail: { productId: input.productId, sha256, sourceSha256: source.draftSha256, version } }); return getContentRevision(db, input.productId, id); }); } export function decideContentRevision(db: Db, input: z.input & { productId: string; revisionId: string; actor: string }) { const parsed = contentDecisionSchema.parse(input); return tx(db, () => { const revision = getContentRevision(db, input.productId, input.revisionId); if (revision.sha256 !== parsed.expectedSha256) throw new ContentConflict("SHA treści nie odpowiada wskazanej wersji."); if (parsed.decision === "approved" && !revision.current) throw new ContentConflict("Treść jest nieaktualna względem faktów lub galerii."); if (revision.decision?.decision === parsed.decision) return revision; const id = newId("cda"); db.prepare("INSERT INTO offer_content_decisions(id,revision_id,content_sha256,decision,decided_by,note,created_at) VALUES(?,?,?,?,?,?,?)") .run(id, revision.id, revision.sha256, parsed.decision, input.actor, parsed.note, nowIso()); audit(db, { actor: input.actor, action: `offer_content.${parsed.decision}`, entity: "offer_content_revisions", entityId: revision.id, detail: { decisionId: id, sha256: revision.sha256, sourceSha256: revision.sourceSha256 } }); return getContentRevision(db, input.productId, input.revisionId); }); }