import type { Db } from "../db.js"; import { nowIso, tx } from "../db.js"; import { newId, ID_PREFIX } from "./ids.js"; import { audit } from "./audit.js"; import type { ApprovalDecision, ApprovalScope, AssetOrigin, ShotRole } from "./types.js"; export interface AssetRow { id: string; product_id: string; variant_id: string | null; kind: string; role: ShotRole; version: number; parent_asset_id: string | null; job_id: string | null; sha256: string; mime: string; bytes: number; width: number | null; height: number | null; path: string; thumb_path: string | null; status: string; origin: AssetOrigin; provenance_json: string; note: string | null; created_at: string; } /** * Rejestruje nowy asset. Nigdy nie nadpisuje — nowy wynik dostaje kolejną * wersję dla (product_id, role). Poprzedni kandydat zostaje oznaczony * jako superseded tylko gdy nowy asset powstaje dla tej samej roli * i ten sam wariant — zatwierdzone wersje zachowują status do jawnej zmiany. */ export function registerAsset( db: Db, input: { productId: string; variantId?: string | null; kind: string; role: ShotRole; parentAssetId?: string | null; jobId?: string | null; sha256: string; mime: string; bytes: number; width?: number | null; height?: number | null; path: string; thumbPath?: string | null; origin: AssetOrigin; provenance?: Record; note?: string | null; } ): AssetRow { const now = nowIso(); return tx(db, () => { const maxV = db .prepare( `SELECT COALESCE(MAX(version), 0) AS v FROM assets WHERE product_id = ? AND role = ? AND variant_id IS ?` ) .get(input.productId, input.role, input.variantId ?? null) as unknown as { v: number }; const version = maxV.v + 1; const id = newId(ID_PREFIX.asset); db.prepare( `INSERT INTO assets (id, product_id, variant_id, kind, role, version, parent_asset_id, job_id, sha256, mime, bytes, width, height, path, thumb_path, status, origin, provenance_json, note, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'candidate', ?, ?, ?, ?)` ).run( id, input.productId, input.variantId ?? null, input.kind, input.role, version, input.parentAssetId ?? null, input.jobId ?? null, input.sha256, input.mime, input.bytes, input.width ?? null, input.height ?? null, input.path, input.thumbPath ?? null, input.origin, JSON.stringify(input.provenance ?? {}), input.note ?? null, now ); return db.prepare("SELECT * FROM assets WHERE id = ?").get(id) as unknown as AssetRow; }); } export function listAssets(db: Db, productId: string, role?: ShotRole): AssetRow[] { if (role) { return db .prepare("SELECT * FROM assets WHERE product_id = ? AND role = ? ORDER BY version DESC") .all(productId, role) as unknown as AssetRow[]; } return db .prepare("SELECT * FROM assets WHERE product_id = ? ORDER BY role, version DESC") .all(productId) as unknown as AssetRow[]; } export function getAsset(db: Db, id: string): AssetRow | null { const row = db.prepare("SELECT * FROM assets WHERE id = ?").get(id) as unknown as | AssetRow | undefined; return row ?? null; } export interface ApprovalRow { id: string; product_id: string; scope: ApprovalScope; target: string; asset_id: string | null; asset_sha256: string | null; variant_id: string | null; decision: ApprovalDecision; decided_by: string | null; note: string | null; created_at: string; } /** * Zatwierdzenie wiąże się z konkretną treścią. Decyzja odrzucenia * ustawia status assetu; zatwierdzenie oznacza asset jako approved * i wycofuje status approved z pozostałych kandydatów tej samej roli * i wariantu (→ superseded), bo jedna rola ma jeden zatwierdzony asset. */ export interface DecisionInput { productId: string; scope: ApprovalScope; target: string; assetId?: string | null; variantId?: string | null; decision: ApprovalDecision; decidedBy?: string | null; note?: string | null; expectedSha256?: string; } export function decide(db: Db, input: DecisionInput): ApprovalRow { return tx(db, () => recordDecision(db, input)); } export function decideBatch(db: Db, inputs: DecisionInput[], validate: () => void): ApprovalRow[] { return tx(db, () => { validate(); return inputs.map((input) => recordDecision(db, input)); }); } function recordDecision(db: Db, input: DecisionInput): ApprovalRow { const now = nowIso(); let assetSha: string | null = null; if (input.assetId) { const asset = getAsset(db, input.assetId); if (!asset) throw new Error(`Nie ma assetu ${input.assetId}`); const scope = asset.variant_id ? "variant_media" : asset.role === "document" ? "document" : "shot"; if (asset.product_id !== input.productId || asset.role !== input.target || asset.variant_id !== (input.variantId ?? null) || input.scope !== scope) { throw new Error("Decyzja nie odpowiada produktowi, wariantowi lub roli assetu."); } if (input.expectedSha256 !== undefined && input.expectedSha256 !== asset.sha256) { throw new Error("Treść assetu zmieniła się od podglądu. Odśwież przegląd."); } assetSha = asset.sha256; if (input.decision === "approved") { db.prepare( `UPDATE assets SET status = 'superseded' WHERE product_id = ? AND role = ? AND variant_id IS ? AND id != ? AND status = 'approved'` ).run(asset.product_id, asset.role, asset.variant_id, asset.id); db.prepare("UPDATE assets SET status = 'approved' WHERE id = ?").run(asset.id); } else { db.prepare("UPDATE assets SET status = 'rejected' WHERE id = ?").run(asset.id); } } const id = newId(ID_PREFIX.approval); db.prepare( `INSERT INTO approvals (id, product_id, scope, target, asset_id, asset_sha256, variant_id, decision, decided_by, note, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ).run( id, input.productId, input.scope, input.target, input.assetId ?? null, assetSha, input.variantId ?? null, input.decision, input.decidedBy ?? null, input.note ?? null, now ); audit(db, { actor: input.decidedBy ?? "system", action: `asset.${input.decision}`, entity: "assets", entityId: input.assetId ?? input.target, detail: { productId: input.productId, scope: input.scope, target: input.target, sha256: assetSha }, }); return db.prepare("SELECT * FROM approvals WHERE id = ?").get(id) as unknown as ApprovalRow; } /** * Przywraca odrzucony asset do statusu candidate (ponowna ocena). * To nie jest decyzja merytoryczna — odrzucenie pozostaje w approvals, * tutaj wraca tylko widoczność kandydata w matrycy. */ export function restoreCandidate(db: Db, assetId: string, actor: string | null): AssetRow { return tx(db, () => { const asset = getAsset(db, assetId); if (!asset) throw new Error(`Nie ma assetu ${assetId}`); if (asset.status !== "rejected") throw new Error("Przywracać można tylko odrzucone wersje."); db.prepare("UPDATE assets SET status = 'candidate' WHERE id = ?").run(assetId); audit(db, { actor: actor ?? "system", action: "asset.restored", entity: "assets", entityId: assetId, detail: { productId: asset.product_id, role: asset.role, sha256: asset.sha256 }, }); return getAsset(db, assetId)!; }); } /** * Zatwierdzony asset dla roli — tylko taki, którego treść zgadza się * z ostatnią decyzją "approved". Starsza decyzja o innej treści * nie przenosi się na nową wersję. */ export function approvedAssetForRole( db: Db, productId: string, role: ShotRole, variantId: string | null = null ): AssetRow | null { const row = db .prepare( `SELECT a.* FROM assets a WHERE a.product_id = ? AND a.role = ? AND a.variant_id IS ? AND a.status = 'approved' AND EXISTS ( SELECT 1 FROM approvals ap WHERE ap.asset_id = a.id AND ap.asset_sha256 = a.sha256 AND ap.decision = 'approved' AND ap.product_id = a.product_id AND ap.target = a.role AND ap.variant_id IS a.variant_id AND ap.scope = CASE WHEN a.variant_id IS NOT NULL THEN 'variant_media' WHEN a.role = 'document' THEN 'document' ELSE 'shot' END AND ap.rowid = (SELECT MAX(last.rowid) FROM approvals last WHERE last.asset_id = a.id) ) ORDER BY a.version DESC LIMIT 1` ) .get(productId, role, variantId) as unknown as AssetRow | undefined; return row ?? null; }