From d35eb3534654a658ef507c4a6fea0882c06cd20d Mon Sep 17 00:00:00 2001 From: Cooper Beaman <55124795+lowestprime@users.noreply.github.com> Date: Sat, 11 Jul 2026 05:55:56 -0700 Subject: [PATCH 01/43] feat(data): normalize piece media and commerce policies --- site/app/api/shop/local-reservation/route.ts | 3 +- site/lib/actions.ts | 96 ++- site/lib/catalog.ts | 37 +- site/lib/database-migrations.test.mts | 80 +++ site/lib/database-migrations.ts | 279 ++++++++ site/lib/db.ts | 600 +++++++++++++++--- site/lib/media-reference-transaction.test.mts | 77 +++ site/lib/media.ts | 54 +- site/lib/piece-model.test.mts | 63 ++ site/lib/piece-model.ts | 125 ++++ site/lib/seed.ts | 2 +- site/package.json | 2 +- 12 files changed, 1305 insertions(+), 113 deletions(-) create mode 100644 site/lib/database-migrations.test.mts create mode 100644 site/lib/database-migrations.ts create mode 100644 site/lib/media-reference-transaction.test.mts create mode 100644 site/lib/piece-model.test.mts create mode 100644 site/lib/piece-model.ts diff --git a/site/app/api/shop/local-reservation/route.ts b/site/app/api/shop/local-reservation/route.ts index d2a4dc1..3ece770 100644 --- a/site/app/api/shop/local-reservation/route.ts +++ b/site/app/api/shop/local-reservation/route.ts @@ -4,6 +4,7 @@ import { getCurrentUser } from "@/lib/auth"; import { createDraftOrder, getPiece, getSiteSettings, listCartItems } from "@/lib/db"; import { getDropoffDriveMinutes, getFulfillmentSummary, getWoodshopZip, pieceShippingEnabled } from "@/lib/catalog"; import { calculateCheckoutTotals } from "@/lib/payments"; +import { pieceCanEnterCart } from "@/lib/piece-model"; function redirectTo(path: string, request: Request) { return NextResponse.redirect(new URL(path, request.url)); @@ -34,7 +35,7 @@ export async function POST(request: Request) { const invalidItems: string[] = []; const pieces = cartItems.flatMap((item) => { const piece = getPiece(item.pieceSlug); - if (!piece || piece.priceCents == null) { + if (!piece || !pieceCanEnterCart(piece) || piece.priceCents == null || item.quantity > piece.inventoryCount) { invalidItems.push(item.pieceSlug); return []; } diff --git a/site/lib/actions.ts b/site/lib/actions.ts index 8a0ad1c..8408cd2 100644 --- a/site/lib/actions.ts +++ b/site/lib/actions.ts @@ -45,7 +45,9 @@ import { setPasswordResetToken, updateProject, deleteMediaRecordAndReferences, + finishMediaRenameHistory, renameMediaRecordAndReferences, + startMediaRenameHistory, type CommissionTypeRecord, type MediaAssignmentFilter, type MediaAiFilter, @@ -59,12 +61,22 @@ import { type UserRecord } from "@/lib/db"; import { clearSession, createPasswordHash, createSession, getCurrentUser, requireAdmin, requireUser, verifyLogin } from "@/lib/auth"; -import { persistGeneratedMedia, persistUploadedMedia, renameMediaAsset, deleteMediaAsset, resolveMediaPath } from "@/lib/media"; +import { + finalizeStagedMediaDeletion, + moveMediaAsset, + persistGeneratedMedia, + persistUploadedMedia, + previewMediaRenamePath, + resolveMediaPath, + restoreStagedMediaAsset, + stageMediaAssetDeletion +} from "@/lib/media"; import { calculateCheckoutTotals, createEasyPostShippingLabel, createStripeCheckoutSession, createStripeInvoice, stripeIsConfigured } from "@/lib/payments"; import { sendNotificationEmail, summarizeEmailFailure } from "@/lib/notifications"; import { createCleanedBackgroundVariant, getAiServiceStatus } from "@/lib/ai-services"; import { buildMediaVerificationQueue, type MediaMatchCandidate } from "@/lib/media-audit"; import { categoryKey, normalizePieceCategories, type PieceCategoryIcon } from "@/lib/categories"; +import { getPieceInquiryMode, getPiecePriceMode, getPieceReviewsMode, pieceCanEnterCart } from "@/lib/piece-model"; function revalidatePagePaths(slug: string) { revalidatePath("/", "layout"); revalidatePath("/"); @@ -433,6 +445,10 @@ export async function addToCartAction(formData: FormData) { const user = await getCurrentUser(); const pieceSlug = requiredField(formData.get("pieceSlug"), "Piece"); const quantity = Math.max(1, parseInteger(formData.get("quantity"), 1)); + const piece = getPiece(pieceSlug); + if (!piece || !pieceCanEnterCart(piece) || quantity > piece.inventoryCount) { + redirect(`/shop?error=${encodeURIComponent("This piece is not available for fixed-price reservation.")}`); + } const { saveCartItem } = await import("@/lib/db"); saveCartItem({ cartToken, userEmail: user?.email ?? null, pieceSlug, quantity, options: parseJsonField(formData.get("optionsJson"), {}) }); revalidatePath("/shop"); @@ -456,7 +472,7 @@ export async function startCheckoutAction(formData: FormData) { const invalidItems: string[] = []; const lines = cartItems.flatMap((item) => { const piece = getPiece(item.pieceSlug); - if (!piece || piece.priceCents == null) { + if (!piece || !pieceCanEnterCart(piece) || piece.priceCents == null || item.quantity > piece.inventoryCount) { invalidItems.push(item.pieceSlug); return []; } @@ -943,21 +959,24 @@ export async function savePieceAction(formData: FormData) { const slug = requiredField(formData.get("slug"), "Piece slug"); const current = getPiece(slug); const pieceJson = optionalField(formData.get("pieceJson")); + const priceMode = (formData.has("priceMode") ? optionalField(formData.get("priceMode")) : current ? getPiecePriceMode(current) : "not-listed") as PieceRecord["priceMode"]; + const inquiryMode = (formData.has("inquiryMode") ? optionalField(formData.get("inquiryMode")) : current ? getPieceInquiryMode(current) : "disabled") as PieceRecord["inquiryMode"]; + const reviewsMode = (formData.has("reviewsMode") ? optionalField(formData.get("reviewsMode")) : current ? getPieceReviewsMode(current) : "hidden") as PieceRecord["reviewsMode"]; savePiece(pieceJson ? parseJsonField(formData.get("pieceJson"), current!) : { slug, - title: optionalField(formData.get("title")) || current?.title || slug, - subtitle: optionalField(formData.get("subtitle")) || current?.subtitle || "", + title: formData.has("title") ? requiredField(formData.get("title"), "Piece title") : current?.title || slug, + subtitle: formData.has("subtitle") ? optionalField(formData.get("subtitle")) : current?.subtitle || "", category: optionalField(formData.get("category")) || current?.category || "Tables", status: (optionalField(formData.get("pieceStatus")) || current?.status || "commission") as PieceRecord["status"], publicationStatus: (optionalField(formData.get("publicationStatus")) || current?.publicationStatus || "draft") as PieceRecord["publicationStatus"], - availabilityLabel: optionalField(formData.get("availabilityLabel")) || current?.availabilityLabel || "", - summary: optionalField(formData.get("summary")) || current?.summary || "", - story: optionalField(formData.get("story")) || current?.story || "", - details: parseListField(formData.get("detailsText")).length > 0 ? parseListField(formData.get("detailsText")) : current?.details || [], - tags: parseListField(formData.get("tagsText")).length > 0 ? parseListField(formData.get("tagsText")) : current?.tags || [], - materials: parseListField(formData.get("materialsText")).length > 0 ? parseListField(formData.get("materialsText")) : current?.materials || [], + availabilityLabel: formData.has("availabilityLabel") ? optionalField(formData.get("availabilityLabel")) : current?.availabilityLabel || "", + summary: formData.has("summary") ? optionalField(formData.get("summary")) : current?.summary || "", + story: formData.has("story") ? optionalField(formData.get("story")) : current?.story || "", + details: formData.has("detailsText") ? parseListField(formData.get("detailsText")) : current?.details || [], + tags: formData.has("tagsText") ? parseListField(formData.get("tagsText")) : current?.tags || [], + materials: formData.has("materialsText") ? parseListField(formData.get("materialsText")) : current?.materials || [], dimensions: parseOptionalInteger(formData.get("width")) == null && parseOptionalInteger(formData.get("depth")) == null && parseOptionalInteger(formData.get("height")) == null ? current?.dimensions || null : { @@ -966,17 +985,26 @@ export async function savePieceAction(formData: FormData) { height: parseOptionalInteger(formData.get("height")) ?? current?.dimensions?.height ?? 0, unit: "in" as const }, - priceCents: parseOptionalInteger(formData.get("priceCents")) ?? current?.priceCents ?? null, + priceCents: formData.has("priceCents") ? parseOptionalInteger(formData.get("priceCents")) : current?.priceCents ?? null, + priceMode, + publicPriceLabel: formData.has("publicPriceLabel") ? optionalField(formData.get("publicPriceLabel")) || null : current?.publicPriceLabel ?? null, + internalEstimateCents: formData.has("internalEstimateCents") ? parseOptionalInteger(formData.get("internalEstimateCents")) : current?.internalEstimateCents ?? null, + inquiryMode, + reviewsMode, + processSectionTitle: formData.has("processSectionTitle") ? optionalField(formData.get("processSectionTitle")) || "Build record" : current?.processSectionTitle ?? "Build record", + processSectionIntro: formData.has("processSectionIntro") ? optionalField(formData.get("processSectionIntro")) : current?.processSectionIntro ?? "", + visualizerTemplate: formData.has("visualizerTemplate") ? optionalField(formData.get("visualizerTemplate")) || null : current?.visualizerTemplate ?? null, + commissionTypeSlug: formData.has("commissionTypeSlug") ? optionalField(formData.get("commissionTypeSlug")) || null : current?.commissionTypeSlug ?? null, inventoryCount: parseInteger(formData.get("inventoryCount"), current?.inventoryCount ?? 0), leadTimeDays: parseInteger(formData.get("leadTimeDays"), current?.leadTimeDays ?? 0), - mediaPaths: parseListField(formData.get("mediaPathsText")).length > 0 ? parseListField(formData.get("mediaPathsText")) : current?.mediaPaths || [], + mediaPaths: formData.has("mediaPathsText") ? parseListField(formData.get("mediaPathsText")) : current?.mediaPaths || [], featuredRank: parseInteger(formData.get("featuredRank"), current?.featuredRank ?? 99), ownerEmail: optionalField(formData.get("ownerEmail")) || current?.ownerEmail || "woodsmithbb@proton.me", metadata: { ...(current?.metadata || {}), verifiedMedia: parseBooleanField(formData.get("verifiedMedia")), publicMediaLimit: parseInteger(formData.get("publicMediaLimit"), Number(current?.metadata?.publicMediaLimit ?? 4)), - fulfillmentOptions: parseListField(formData.get("fulfillmentText")).length > 0 ? parseListField(formData.get("fulfillmentText")) : current?.metadata?.fulfillmentOptions ?? [], + fulfillmentOptions: formData.has("fulfillmentText") ? parseListField(formData.get("fulfillmentText")) : current?.metadata?.fulfillmentOptions ?? [], mediaReviewRequired: parseBooleanField(formData.get("mediaReviewRequired")) } }); @@ -1336,13 +1364,24 @@ export async function uploadMediaAction(_: unknown, formData: FormData): Promise export async function renameMediaAction(_: unknown, formData: FormData): Promise { try { - await requireAdmin(); + const admin = await requireAdmin(); const previousPath = requiredField(formData.get("relativePath"), "Media path"); - const nextRelativePath = renameMediaAsset( - previousPath, - requiredField(formData.get("baseName"), "New name") - ); - const affected = renameMediaRecordAndReferences(previousPath, nextRelativePath); + const nextRelativePath = previewMediaRenamePath(previousPath, requiredField(formData.get("baseName"), "New name")); + if (nextRelativePath === previousPath) return { ok: true, kind: "rename", previousPath, relativePath: nextRelativePath }; + const historyId = startMediaRenameHistory(previousPath, nextRelativePath, admin.email); + moveMediaAsset(previousPath, nextRelativePath); + let affected; + try { + affected = renameMediaRecordAndReferences(previousPath, nextRelativePath, { actorEmail: admin.email, historyId }); + } catch (error) { + try { + moveMediaAsset(nextRelativePath, previousPath); + finishMediaRenameHistory(historyId, "rolled-back", error instanceof Error ? error.message : String(error)); + } catch (rollbackError) { + finishMediaRenameHistory(historyId, "failed", `Reference update failed and file rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`); + } + throw error; + } revalidateMediaSurfaces(affected); return { ok: true, kind: "rename", previousPath, relativePath: nextRelativePath }; } catch (error) { @@ -1352,10 +1391,23 @@ export async function renameMediaAction(_: unknown, formData: FormData): Promise export async function deleteMediaAction(_: unknown, formData: FormData): Promise { try { - await requireAdmin(); + const admin = await requireAdmin(); const relativePath = requiredField(formData.get("relativePath"), "Media path"); - deleteMediaAsset(relativePath); - const affected = deleteMediaRecordAndReferences(relativePath); + const staged = stageMediaAssetDeletion(relativePath); + let affected; + try { + affected = deleteMediaRecordAndReferences(relativePath, admin.email); + } catch (error) { + restoreStagedMediaAsset(staged); + throw error; + } + try { + finalizeStagedMediaDeletion(staged); + } catch (error) { + restoreStagedMediaAsset(staged); + refreshMediaLibrary(); + throw error; + } revalidateMediaSurfaces(affected); return { ok: true, kind: "delete", relativePath }; } catch (error) { diff --git a/site/lib/catalog.ts b/site/lib/catalog.ts index 0e9ae1c..c8003df 100644 --- a/site/lib/catalog.ts +++ b/site/lib/catalog.ts @@ -1,4 +1,14 @@ -import type { PieceRecord } from "@/lib/db"; +import { listPieceMediaLinks, type PieceRecord } from "@/lib/db"; +import { + getPieceInquiryMode, + getPiecePriceMode, + getPiecePublicPriceLabel, + getPieceReviewsMode, + pieceAcceptsReviews, + pieceAllowsInquiry, + pieceCanEnterCart, + pieceDisplaysReviews +} from "@/lib/piece-model"; import { defaultPieceCategories, normalizePieceCategories, pieceCategoryKey, type PieceCategoryDefinition } from "@/lib/categories"; export function getPortfolioCategories(value?: unknown) { @@ -12,17 +22,34 @@ export function getPiecePortfolioCategory( return pieceCategoryKey(piece.category, categories); } -export function getDisplayMediaPaths(piece: Pick) { +export function getDisplayMediaPaths(piece: Pick) { const preferredLimit = Number(piece.metadata?.publicMediaLimit ?? (piece.status === "inventory" ? 5 : 4)); const safeLimit = Number.isFinite(preferredLimit) ? Math.max(1, Math.min(12, Math.round(preferredLimit))) : 4; - return piece.mediaPaths.slice(0, safeLimit); + const links = listPieceMediaLinks(piece.slug, { publicOnly: true, roles: ["hero", "gallery", "detail", "context"] }); + const normalized = [...new Set(links.map((link) => link.relativePath))]; + return (normalized.length > 0 ? normalized : piece.mediaPaths).slice(0, safeLimit); } -export function hasVerifiedMedia(piece: Pick) { - if (piece.mediaPaths.length === 0) return false; +export function getPieceProcessMediaLinks(piece: Pick) { + return listPieceMediaLinks(piece.slug, { publicOnly: true, roles: ["process", "drawing", "plan", "installation"] }); +} + +export function hasVerifiedMedia(piece: Pick) { + if (getDisplayMediaPaths(piece).length === 0) return false; return piece.metadata?.verifiedMedia !== false; } +export { + getPieceInquiryMode, + getPiecePriceMode, + getPiecePublicPriceLabel, + getPieceReviewsMode, + pieceAcceptsReviews, + pieceAllowsInquiry, + pieceCanEnterCart, + pieceDisplaysReviews +}; + export function pieceShippingEnabled(piece: Pick) { return Boolean(piece.metadata?.shippingEnabled || piece.metadata?.shipEligible || piece.metadata?.shippingAllowed); } diff --git a/site/lib/database-migrations.test.mts b/site/lib/database-migrations.test.mts new file mode 100644 index 0000000..0e9413f --- /dev/null +++ b/site/lib/database-migrations.test.mts @@ -0,0 +1,80 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { applySchemaMigrations } from "./database-migrations.ts"; + +function fixtureDatabase(options: { omitPriceColumn?: boolean } = {}) { + const directory = mkdtempSync(path.join(tmpdir(), "woodsmith-migration-")); + const db = new DatabaseSync(path.join(directory, "fixture.sqlite")); + db.exec(` + PRAGMA foreign_keys = ON; + CREATE TABLE pieces ( + slug TEXT PRIMARY KEY, + status TEXT NOT NULL, + publication_status TEXT NOT NULL, + availability_label TEXT NOT NULL DEFAULT '', + ${options.omitPriceColumn ? "" : "price_cents INTEGER,"} + media_paths_json TEXT NOT NULL DEFAULT '[]', + metadata_json TEXT NOT NULL DEFAULT '{}' + ) STRICT; + CREATE TABLE media_items ( + relative_path TEXT PRIMARY KEY + ) STRICT; + `); + return { db, directory }; +} + +test("schema migrations are additive, idempotent, and preserve legacy truth", () => { + const { db, directory } = fixtureDatabase(); + try { + db.prepare(`INSERT INTO media_items (relative_path) VALUES (?)`).run("Furniture/pastry-table/hero.jpg"); + const insertPiece = db.prepare(` + INSERT INTO pieces (slug, status, publication_status, availability_label, price_cents, media_paths_json, metadata_json) + VALUES (?, ?, ?, ?, ?, ?, ?) + `); + insertPiece.run("pastry-table", "inventory", "published", "Available", -1, JSON.stringify(["Furniture/pastry-table/hero.jpg", "missing.jpg"]), JSON.stringify({ verifiedMedia: true })); + insertPiece.run("fixed-piece", "inventory", "published", "Available", 125000, "[]", "{}"); + insertPiece.run("archive-piece", "archive", "published", "Unavailable", null, "[]", "{}"); + + const first = applySchemaMigrations(db); + const second = applySchemaMigrations(db); + assert.equal(first.quickCheckBefore, "ok"); + assert.equal(first.quickCheckAfter, "ok"); + assert.deepEqual(first.applied.map((entry) => entry.version), [1, 2, 3]); + assert.equal(second.applied.length, 0); + + const policies = (db.prepare(`SELECT slug, price_mode AS priceMode, price_cents AS priceCents, inquiry_mode AS inquiryMode, reviews_mode AS reviewsMode FROM pieces ORDER BY slug`).all() as Array>).map((row) => ({ ...row })); + assert.deepEqual(policies, [ + { slug: "archive-piece", priceMode: "not-listed", priceCents: null, inquiryMode: "related-commission", reviewsMode: "display-and-accept" }, + { slug: "fixed-piece", priceMode: "fixed", priceCents: 125000, inquiryMode: "exact-piece", reviewsMode: "display-and-accept" }, + { slug: "pastry-table", priceMode: "contact-for-price", priceCents: null, inquiryMode: "exact-piece", reviewsMode: "display-and-accept" } + ]); + + const links = (db.prepare(`SELECT piece_slug AS pieceSlug, relative_path AS relativePath, role, display_order AS displayOrder, is_public AS public FROM piece_media_links`).all() as Array>).map((row) => ({ ...row })); + assert.deepEqual(links, [{ pieceSlug: "pastry-table", relativePath: "Furniture/pastry-table/hero.jpg", role: "hero", displayOrder: 0, public: 1 }]); + const report = db.prepare(`SELECT report_json AS reportJson FROM schema_migrations WHERE version = 3`).get() as { reportJson: string }; + assert.equal(JSON.parse(report.reportJson).missingCount, 1); + } finally { + db.close(); + rmSync(directory, { recursive: true, force: true }); + } +}); + +test("a failing migration rolls back its schema changes and ledger row", () => { + const { db, directory } = fixtureDatabase({ omitPriceColumn: true }); + try { + assert.throws(() => applySchemaMigrations(db), /price_cents/); + const versions = db.prepare(`SELECT version FROM schema_migrations ORDER BY version`).all() as Array<{ version: number }>; + assert.deepEqual(versions.map((row) => row.version), [1]); + const columns = db.prepare(`PRAGMA table_info(pieces)`).all() as Array<{ name: string }>; + assert.equal(columns.some((column) => column.name === "price_mode"), false); + const quick = db.prepare(`PRAGMA quick_check`).get() as Record; + assert.equal(String(Object.values(quick)[0]), "ok"); + } finally { + db.close(); + rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/site/lib/database-migrations.ts b/site/lib/database-migrations.ts new file mode 100644 index 0000000..5e97432 --- /dev/null +++ b/site/lib/database-migrations.ts @@ -0,0 +1,279 @@ +import { randomUUID } from "node:crypto"; +import type { DatabaseSync } from "node:sqlite"; +import { + inferLegacyInquiryMode, + inferLegacyPriceMode, + inferLegacyReviewsMode, + normalizeInquiryMode, + normalizePriceMode, + normalizeReviewsMode +} from "./piece-model.ts"; + +type MigrationReport = Record; + +type Migration = { + version: number; + name: string; + checksum: string; + apply: (db: DatabaseSync) => MigrationReport; +}; + +export type SchemaMigrationResult = { + applied: Array<{ version: number; name: string; report: MigrationReport }>; + quickCheckBefore: string; + quickCheckAfter: string; +}; + +function nowIso() { + return new Date().toISOString(); +} + +function quickCheck(db: DatabaseSync) { + const row = db.prepare("PRAGMA quick_check").get() as Record | undefined; + return String(Object.values(row ?? {})[0] ?? "unknown"); +} + +function readJson(value: unknown, fallback: T): T { + if (typeof value !== "string" || !value) return fallback; + try { + return JSON.parse(value) as T; + } catch { + return fallback; + } +} + +function tableColumns(db: DatabaseSync, table: string) { + const rows = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name?: unknown }>; + return new Set(rows.map((row) => String(row.name ?? ""))); +} + +function addColumn(db: DatabaseSync, table: string, column: string, definition: string) { + if (tableColumns(db, table).has(column)) return false; + db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`); + return true; +} + +const migrations: Migration[] = [ + { + version: 1, + name: "normalized-piece-media-and-audit-ledgers", + checksum: "2026-07-piece-media-audit-v1", + apply(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS piece_media_links ( + id TEXT PRIMARY KEY, + piece_slug TEXT NOT NULL, + relative_path TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('hero', 'gallery', 'detail', 'context', 'process', 'drawing', 'plan', 'installation', 'source', 'private-project')), + stage TEXT, + occurred_at TEXT, + title TEXT NOT NULL DEFAULT '', + caption TEXT NOT NULL DEFAULT '', + technical_note TEXT NOT NULL DEFAULT '', + alt_override TEXT, + display_order INTEGER NOT NULL DEFAULT 0, + is_public INTEGER NOT NULL DEFAULT 0 CHECK (is_public IN (0, 1)), + legacy_synced INTEGER NOT NULL DEFAULT 0 CHECK (legacy_synced IN (0, 1)), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (piece_slug) REFERENCES pieces(slug) ON UPDATE CASCADE ON DELETE CASCADE, + FOREIGN KEY (relative_path) REFERENCES media_items(relative_path) ON UPDATE CASCADE ON DELETE CASCADE + ) STRICT; + + CREATE UNIQUE INDEX IF NOT EXISTS idx_piece_media_unique_role_stage + ON piece_media_links(piece_slug, relative_path, role, IFNULL(stage, '')); + CREATE INDEX IF NOT EXISTS idx_piece_media_piece_order + ON piece_media_links(piece_slug, is_public, role, display_order); + CREATE INDEX IF NOT EXISTS idx_piece_media_path + ON piece_media_links(relative_path); + + CREATE TABLE IF NOT EXISTS admin_edit_audit ( + id TEXT PRIMARY KEY, + actor_email TEXT, + entity_type TEXT NOT NULL, + entity_key TEXT NOT NULL, + operation TEXT NOT NULL, + before_json TEXT NOT NULL DEFAULT 'null', + after_json TEXT NOT NULL DEFAULT 'null', + request_id TEXT, + reverted_by_id TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY (reverted_by_id) REFERENCES admin_edit_audit(id) + ) STRICT; + CREATE INDEX IF NOT EXISTS idx_admin_edit_audit_entity + ON admin_edit_audit(entity_type, entity_key, created_at DESC); + + CREATE TABLE IF NOT EXISTS media_rename_history ( + id TEXT PRIMARY KEY, + previous_path TEXT NOT NULL, + next_path TEXT, + status TEXT NOT NULL CHECK (status IN ('planned', 'completed', 'rolled-back', 'failed', 'deleted')), + actor_email TEXT, + error TEXT, + rollback_of TEXT, + created_at TEXT NOT NULL, + completed_at TEXT, + FOREIGN KEY (rollback_of) REFERENCES media_rename_history(id) + ) STRICT; + CREATE INDEX IF NOT EXISTS idx_media_rename_previous + ON media_rename_history(previous_path, created_at DESC); + CREATE INDEX IF NOT EXISTS idx_media_rename_next + ON media_rename_history(next_path, created_at DESC); + `); + + return { tables: ["piece_media_links", "admin_edit_audit", "media_rename_history"] }; + } + }, + { + version: 2, + name: "typed-piece-policy-fields", + checksum: "2026-07-piece-policy-v1", + apply(db) { + const added: string[] = []; + const add = (column: string, definition: string) => { + if (addColumn(db, "pieces", column, definition)) added.push(column); + }; + + add("price_mode", "TEXT NOT NULL DEFAULT 'not-listed' CHECK (price_mode IN ('fixed', 'not-listed', 'contact-for-price', 'determined-after-approval', 'determined-at-order-completion'))"); + add("public_price_label", "TEXT"); + add("internal_estimate_cents", "INTEGER"); + add("inquiry_mode", "TEXT NOT NULL DEFAULT 'disabled' CHECK (inquiry_mode IN ('disabled', 'exact-piece', 'custom-pattern', 'related-commission'))"); + add("reviews_mode", "TEXT NOT NULL DEFAULT 'hidden' CHECK (reviews_mode IN ('hidden', 'display-approved', 'display-and-accept'))"); + add("process_section_title", "TEXT NOT NULL DEFAULT 'Build record'"); + add("process_section_intro", "TEXT NOT NULL DEFAULT ''"); + add("visualizer_template", "TEXT"); + add("commission_type_slug", "TEXT"); + + const pieces = db.prepare(` + SELECT slug, status, publication_status AS publicationStatus, + availability_label AS availabilityLabel, price_cents AS priceCents, + metadata_json AS metadataJson + FROM pieces + `).all() as Array>; + + let fixed = 0; + let notListed = 0; + let contact = 0; + let approval = 0; + for (const piece of pieces) { + const metadata = readJson>(piece.metadataJson, {}); + const source = { + status: String(piece.status) as "inventory" | "commission" | "archive", + publicationStatus: String(piece.publicationStatus) as "published" | "draft" | "archived", + availabilityLabel: String(piece.availabilityLabel ?? ""), + priceCents: piece.priceCents == null ? null : Number(piece.priceCents) + }; + const priceMode = normalizePriceMode(metadata.priceMode, inferLegacyPriceMode(source)); + const inquiryMode = normalizeInquiryMode(metadata.inquiryMode, inferLegacyInquiryMode(source)); + const reviewsMode = normalizeReviewsMode(metadata.reviewsMode, inferLegacyReviewsMode(source)); + const safePrice = priceMode === "fixed" && Number(source.priceCents) > 0 ? source.priceCents : null; + + db.prepare(` + UPDATE pieces + SET price_mode = ?, price_cents = ?, public_price_label = ?, internal_estimate_cents = ?, + inquiry_mode = ?, reviews_mode = ?, process_section_title = ?, process_section_intro = ?, + visualizer_template = ?, commission_type_slug = ? + WHERE slug = ? + `).run( + priceMode, + safePrice, + typeof metadata.publicPriceLabel === "string" ? metadata.publicPriceLabel : null, + Number.isInteger(Number(metadata.internalEstimateCents)) && Number(metadata.internalEstimateCents) >= 0 ? Number(metadata.internalEstimateCents) : null, + inquiryMode, + reviewsMode, + typeof metadata.processSectionTitle === "string" && metadata.processSectionTitle.trim() ? metadata.processSectionTitle : "Build record", + typeof metadata.processSectionIntro === "string" ? metadata.processSectionIntro : "", + typeof metadata.visualizerTemplate === "string" ? metadata.visualizerTemplate : null, + typeof metadata.commissionTypeSlug === "string" ? metadata.commissionTypeSlug : null, + String(piece.slug) + ); + + if (priceMode === "fixed") fixed += 1; + else if (priceMode === "contact-for-price") contact += 1; + else if (priceMode === "determined-after-approval") approval += 1; + else notListed += 1; + } + + return { addedColumns: added, migratedPieces: pieces.length, priceModes: { fixed, notListed, contact, approval } }; + } + }, + { + version: 3, + name: "legacy-piece-media-link-backfill", + checksum: "2026-07-piece-media-backfill-v1", + apply(db) { + const pieces = db.prepare(`SELECT slug, media_paths_json AS mediaPathsJson, metadata_json AS metadataJson FROM pieces`).all() as Array>; + const mediaExists = db.prepare("SELECT 1 AS present FROM media_items WHERE relative_path = ? LIMIT 1"); + const insert = db.prepare(` + INSERT OR IGNORE INTO piece_media_links ( + id, piece_slug, relative_path, role, stage, occurred_at, title, caption, + technical_note, alt_override, display_order, is_public, legacy_synced, created_at, updated_at + ) VALUES (?, ?, ?, ?, NULL, NULL, '', '', '', NULL, ?, ?, 1, ?, ?) + `); + let inserted = 0; + const missing: Array<{ pieceSlug: string; relativePath: string }> = []; + const timestamp = nowIso(); + + for (const piece of pieces) { + const mediaPaths = readJson(piece.mediaPathsJson, []).filter((value) => typeof value === "string" && value.trim()); + const metadata = readJson>(piece.metadataJson, {}); + const isPublic = metadata.verifiedMedia !== false ? 1 : 0; + mediaPaths.forEach((relativePath, index) => { + if (!mediaExists.get(relativePath)) { + missing.push({ pieceSlug: String(piece.slug), relativePath }); + return; + } + const result = insert.run(randomUUID(), String(piece.slug), relativePath, index === 0 ? "hero" : "gallery", index, isPublic, timestamp, timestamp); + inserted += Number(result.changes ?? 0); + }); + } + + return { inserted, missing, missingCount: missing.length }; + } + } +]; + +export function applySchemaMigrations(db: DatabaseSync): SchemaMigrationResult { + db.exec(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + name TEXT NOT NULL, + checksum TEXT NOT NULL, + report_json TEXT NOT NULL DEFAULT '{}', + applied_at TEXT NOT NULL + ) STRICT; + `); + + const quickCheckBefore = quickCheck(db); + if (quickCheckBefore !== "ok") throw new Error(`SQLite quick_check failed before migration: ${quickCheckBefore}`); + + const applied: SchemaMigrationResult["applied"] = []; + const current = db.prepare("SELECT version, name, checksum FROM schema_migrations").all() as Array>; + const byVersion = new Map(current.map((row) => [Number(row.version), row])); + + for (const migration of migrations) { + const existing = byVersion.get(migration.version); + if (existing) { + if (String(existing.name) !== migration.name || String(existing.checksum) !== migration.checksum) { + throw new Error(`Migration ${migration.version} identity does not match the applied migration ledger.`); + } + continue; + } + + db.exec("BEGIN IMMEDIATE"); + try { + const report = migration.apply(db); + db.prepare(`INSERT INTO schema_migrations (version, name, checksum, report_json, applied_at) VALUES (?, ?, ?, ?, ?)`) + .run(migration.version, migration.name, migration.checksum, JSON.stringify(report), nowIso()); + db.exec("COMMIT"); + applied.push({ version: migration.version, name: migration.name, report }); + } catch (error) { + db.exec("ROLLBACK"); + throw error; + } + } + + const quickCheckAfter = quickCheck(db); + if (quickCheckAfter !== "ok") throw new Error(`SQLite quick_check failed after migration: ${quickCheckAfter}`); + return { applied, quickCheckBefore, quickCheckAfter }; +} diff --git a/site/lib/db.ts b/site/lib/db.ts index 71e1c5f..3c9ee4e 100644 --- a/site/lib/db.ts +++ b/site/lib/db.ts @@ -1,4 +1,4 @@ -import { accessSync, constants as fsConstants, mkdirSync } from "node:fs"; +import { accessSync, constants as fsConstants, existsSync, mkdirSync } from "node:fs"; import path from "node:path"; import { randomUUID } from "node:crypto"; import { DatabaseSync } from "node:sqlite"; @@ -9,9 +9,21 @@ import { seedPosts, seedProfiles, siteSettingsSeed -} from "@/lib/seed"; -import { scanMediaLibrary } from "@/lib/media"; -import { normalizePieceCategories } from "@/lib/categories"; +} from "./seed.ts"; +import { scanMediaLibrary } from "./media.ts"; +import { normalizePieceCategories } from "./categories.ts"; +import { applySchemaMigrations } from "./database-migrations.ts"; +import { + getPieceInquiryMode, + getPiecePriceMode, + getPieceReviewsMode, + normalizeInquiryMode, + normalizePriceMode, + normalizeReviewsMode, + type InquiryMode, + type PriceMode, + type ReviewsMode +} from "./piece-model.ts"; export type UserRole = "admin" | "woodworker" | "customer"; export type PublicationStatus = "published" | "draft" | "archived"; @@ -70,6 +82,15 @@ export type PieceRecord = { materials: string[]; dimensions: { width: number; depth: number; height: number; unit: "in" } | null; priceCents: number | null; + priceMode?: PriceMode; + publicPriceLabel?: string | null; + internalEstimateCents?: number | null; + inquiryMode?: InquiryMode; + reviewsMode?: ReviewsMode; + processSectionTitle?: string; + processSectionIntro?: string; + visualizerTemplate?: string | null; + commissionTypeSlug?: string | null; inventoryCount: number; leadTimeDays: number; mediaPaths: string[]; @@ -80,6 +101,52 @@ export type PieceRecord = { updatedAt: string; }; +export const PIECE_MEDIA_ROLES = ["hero", "gallery", "detail", "context", "process", "drawing", "plan", "installation", "source", "private-project"] as const; +export type PieceMediaRole = (typeof PIECE_MEDIA_ROLES)[number]; + +export type PieceMediaLinkRecord = { + id: string; + pieceSlug: string; + relativePath: string; + role: PieceMediaRole; + stage: string | null; + occurredAt: string | null; + title: string; + caption: string; + technicalNote: string; + altOverride: string | null; + displayOrder: number; + public: boolean; + legacySynced: boolean; + createdAt: string; + updatedAt: string; +}; + +export type AdminEditAuditRecord = { + id: string; + actorEmail: string | null; + entityType: string; + entityKey: string; + operation: string; + before: unknown; + after: unknown; + requestId: string | null; + revertedById: string | null; + createdAt: string; +}; + +export type MediaRenameHistoryRecord = { + id: string; + previousPath: string; + nextPath: string | null; + status: "planned" | "completed" | "rolled-back" | "failed" | "deleted"; + actorEmail: string | null; + error: string | null; + rollbackOf: string | null; + createdAt: string; + completedAt: string | null; +}; + export type PostRecord = { slug: string; title: string; @@ -266,11 +333,31 @@ let database: DatabaseSync | null = null; let initialized = false; let activeDataDir: string | null = null; let activeDatabasePath: string | null = null; +let transactionDepth = 0; function nowIso() { return new Date().toISOString(); } +export function withDatabaseTransaction(work: (db: DatabaseSync) => T): T { + const db = getDatabase(); + const depth = transactionDepth; + const savepoint = `woodsmith_${depth}`; + transactionDepth += 1; + db.exec(depth === 0 ? "BEGIN IMMEDIATE" : `SAVEPOINT ${savepoint}`); + try { + const result = work(db); + db.exec(depth === 0 ? "COMMIT" : `RELEASE SAVEPOINT ${savepoint}`); + return result; + } catch (error) { + db.exec(depth === 0 ? "ROLLBACK" : `ROLLBACK TO SAVEPOINT ${savepoint}`); + if (depth > 0) db.exec(`RELEASE SAVEPOINT ${savepoint}`); + throw error; + } finally { + transactionDepth -= 1; + } +} + function toBoolean(value: unknown) { return Number(value) === 1; } @@ -304,11 +391,14 @@ function getDatabase() { mkdirSync(dataDir, { recursive: true }); activeDataDir = dataDir; activeDatabasePath = path.join(dataDir, "woodsmith.sqlite"); + const databaseExisted = existsSync(activeDatabasePath); database = new DatabaseSync(activeDatabasePath); - database.exec(` + try { + database.exec(` PRAGMA journal_mode = WAL; - PRAGMA foreign_keys = OFF; + PRAGMA foreign_keys = ON; + PRAGMA busy_timeout = 5000; CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, @@ -561,16 +651,38 @@ function getDatabase() { CREATE INDEX IF NOT EXISTS idx_media_piece_slug ON media_items(piece_slug); CREATE INDEX IF NOT EXISTS idx_media_project_reference ON media_items(project_reference); CREATE INDEX IF NOT EXISTS idx_embedding_cache_kind ON embedding_cache(kind); - `); + `); + + if (!initialized) { + ensureUserVerificationColumns(database); + const seededVersionBeforeInitialization = getSeededVersion(database); + seedDefaultContent(database); + syncMediaLibraryIntoDatabase(database, { + applySeedAssignments: !databaseExisted && seededVersionBeforeInitialization === 0 + }); + applySchemaMigrations(database); + initialized = true; + } - if (!initialized) { - ensureUserVerificationColumns(database); - seedDefaultContent(database); - syncMediaLibraryIntoDatabase(database); - initialized = true; + return database; + } catch (error) { + database.close(); + database = null; + initialized = false; + activeDataDir = null; + activeDatabasePath = null; + throw error; } +} - return database; +export function closeDatabaseForTests() { + if (process.env.NODE_ENV !== "test") throw new Error("Database reset is available only in the test environment."); + database?.close(); + database = null; + initialized = false; + activeDataDir = null; + activeDatabasePath = null; + transactionDepth = 0; } function ensureUserVerificationColumns(db: DatabaseSync) { @@ -629,6 +741,7 @@ export type RuntimePersistenceStatus = { quickCheck: string; journalMode: string; seededVersion: number; + schemaVersion: number; }; export function getRuntimePersistenceStatus(): RuntimePersistenceStatus { @@ -646,6 +759,7 @@ export function getRuntimePersistenceStatus(): RuntimePersistenceStatus { const quickCheckRow = db.prepare(`PRAGMA quick_check`).get() as Record | undefined; const journalModeRow = db.prepare(`PRAGMA journal_mode`).get() as Record | undefined; + const schemaVersionRow = db.prepare(`SELECT COALESCE(MAX(version), 0) AS version FROM schema_migrations`).get() as { version?: unknown } | undefined; return { dataRoot, @@ -654,7 +768,8 @@ export function getRuntimePersistenceStatus(): RuntimePersistenceStatus { dataRootWritable, quickCheck: String(Object.values(quickCheckRow ?? {})[0] ?? "unknown"), journalMode: String(Object.values(journalModeRow ?? {})[0] ?? "unknown"), - seededVersion: getSeededVersion(db) + seededVersion: getSeededVersion(db), + schemaVersion: Number(schemaVersionRow?.version ?? 0) }; } @@ -1085,7 +1200,7 @@ function seedDefaultContent(db: DatabaseSync) { } } -function syncMediaLibraryIntoDatabase(db: DatabaseSync) { +function syncMediaLibraryIntoDatabase(db: DatabaseSync, options: { applySeedAssignments?: boolean } = {}) { const scanned = scanMediaLibrary(); for (const media of scanned) { @@ -1173,21 +1288,23 @@ function syncMediaLibraryIntoDatabase(db: DatabaseSync) { }); } - for (const piece of seedPieces) { - for (const relativePath of piece.mediaPaths) { - db.prepare(`UPDATE media_items SET piece_slug = COALESCE(piece_slug, ?), reviewed = 1 WHERE relative_path = ?`).run(piece.slug, relativePath); + if (options.applySeedAssignments) { + for (const piece of seedPieces) { + for (const relativePath of piece.mediaPaths) { + db.prepare(`UPDATE media_items SET piece_slug = COALESCE(piece_slug, ?), reviewed = 1 WHERE relative_path = ?`).run(piece.slug, relativePath); + } } - } - for (const post of seedPosts) { - if (post.coverMediaPath) { - db.prepare(`UPDATE media_items SET post_slug = COALESCE(post_slug, ?), reviewed = 1 WHERE relative_path = ?`).run(post.slug, post.coverMediaPath); + for (const post of seedPosts) { + if (post.coverMediaPath) { + db.prepare(`UPDATE media_items SET post_slug = COALESCE(post_slug, ?), reviewed = 1 WHERE relative_path = ?`).run(post.slug, post.coverMediaPath); + } } - } - for (const page of seedPages) { - if (page.heroMediaPath) { - db.prepare(`UPDATE media_items SET page_slug = COALESCE(page_slug, ?), reviewed = 1 WHERE relative_path = ?`).run(page.slug, page.heroMediaPath); + for (const page of seedPages) { + if (page.heroMediaPath) { + db.prepare(`UPDATE media_items SET page_slug = COALESCE(page_slug, ?), reviewed = 1 WHERE relative_path = ?`).run(page.slug, page.heroMediaPath); + } } } } @@ -1230,6 +1347,17 @@ function mapPage(row: Record): PageRecord { } function mapPiece(row: Record): PieceRecord { + const metadata = readJson>(row.metadataJson, {}); + const policySource = { + status: row.status as PieceStatus, + publicationStatus: row.publicationStatus as PublicationStatus, + availabilityLabel: String(row.availabilityLabel ?? ""), + priceCents: row.priceCents == null ? null : Number(row.priceCents), + priceMode: row.priceMode ? normalizePriceMode(row.priceMode) : null, + inquiryMode: row.inquiryMode ? normalizeInquiryMode(row.inquiryMode) : null, + reviewsMode: row.reviewsMode ? normalizeReviewsMode(row.reviewsMode) : null, + metadata + }; return { slug: String(row.slug), title: String(row.title), @@ -1244,13 +1372,22 @@ function mapPiece(row: Record): PieceRecord { tags: readJson(row.tagsJson, []), materials: readJson(row.materialsJson, []), dimensions: readJson(row.dimensionsJson, null), - priceCents: row.priceCents == null ? null : Number(row.priceCents), + priceCents: policySource.priceCents != null && policySource.priceCents > 0 ? policySource.priceCents : null, + priceMode: getPiecePriceMode(policySource), + publicPriceLabel: row.publicPriceLabel ? String(row.publicPriceLabel) : null, + internalEstimateCents: row.internalEstimateCents == null ? null : Math.max(0, Number(row.internalEstimateCents)), + inquiryMode: getPieceInquiryMode(policySource), + reviewsMode: getPieceReviewsMode(policySource), + processSectionTitle: String(row.processSectionTitle ?? "Build record"), + processSectionIntro: String(row.processSectionIntro ?? ""), + visualizerTemplate: row.visualizerTemplate ? String(row.visualizerTemplate) : null, + commissionTypeSlug: row.commissionTypeSlug ? String(row.commissionTypeSlug) : null, inventoryCount: Number(row.inventoryCount ?? 0), leadTimeDays: Number(row.leadTimeDays ?? 0), mediaPaths: readJson(row.mediaPathsJson, []), featuredRank: Number(row.featuredRank ?? 999), ownerEmail: row.ownerEmail ? String(row.ownerEmail) : null, - metadata: readJson(row.metadataJson, {}), + metadata, createdAt: String(row.createdAt), updatedAt: String(row.updatedAt) }; @@ -1776,25 +1913,61 @@ export function deletePage(slug: string) { export function listPieces(includeDraft = false) { const db = getDatabase(); const query = includeDraft - ? `SELECT slug, title, subtitle, category, status, publication_status AS publicationStatus, availability_label AS availabilityLabel, summary, story, details_json AS detailsJson, tags_json AS tagsJson, materials_json AS materialsJson, dimensions_json AS dimensionsJson, price_cents AS priceCents, inventory_count AS inventoryCount, lead_time_days AS leadTimeDays, media_paths_json AS mediaPathsJson, featured_rank AS featuredRank, owner_email AS ownerEmail, metadata_json AS metadataJson, created_at AS createdAt, updated_at AS updatedAt FROM pieces ORDER BY featured_rank ASC, title ASC` - : `SELECT slug, title, subtitle, category, status, publication_status AS publicationStatus, availability_label AS availabilityLabel, summary, story, details_json AS detailsJson, tags_json AS tagsJson, materials_json AS materialsJson, dimensions_json AS dimensionsJson, price_cents AS priceCents, inventory_count AS inventoryCount, lead_time_days AS leadTimeDays, media_paths_json AS mediaPathsJson, featured_rank AS featuredRank, owner_email AS ownerEmail, metadata_json AS metadataJson, created_at AS createdAt, updated_at AS updatedAt FROM pieces WHERE publication_status = 'published' ORDER BY featured_rank ASC, title ASC`; + ? `SELECT slug, title, subtitle, category, status, publication_status AS publicationStatus, availability_label AS availabilityLabel, summary, story, details_json AS detailsJson, tags_json AS tagsJson, materials_json AS materialsJson, dimensions_json AS dimensionsJson, price_cents AS priceCents, price_mode AS priceMode, public_price_label AS publicPriceLabel, internal_estimate_cents AS internalEstimateCents, inquiry_mode AS inquiryMode, reviews_mode AS reviewsMode, process_section_title AS processSectionTitle, process_section_intro AS processSectionIntro, visualizer_template AS visualizerTemplate, commission_type_slug AS commissionTypeSlug, inventory_count AS inventoryCount, lead_time_days AS leadTimeDays, media_paths_json AS mediaPathsJson, featured_rank AS featuredRank, owner_email AS ownerEmail, metadata_json AS metadataJson, created_at AS createdAt, updated_at AS updatedAt FROM pieces ORDER BY featured_rank ASC, title ASC` + : `SELECT slug, title, subtitle, category, status, publication_status AS publicationStatus, availability_label AS availabilityLabel, summary, story, details_json AS detailsJson, tags_json AS tagsJson, materials_json AS materialsJson, dimensions_json AS dimensionsJson, price_cents AS priceCents, price_mode AS priceMode, public_price_label AS publicPriceLabel, internal_estimate_cents AS internalEstimateCents, inquiry_mode AS inquiryMode, reviews_mode AS reviewsMode, process_section_title AS processSectionTitle, process_section_intro AS processSectionIntro, visualizer_template AS visualizerTemplate, commission_type_slug AS commissionTypeSlug, inventory_count AS inventoryCount, lead_time_days AS leadTimeDays, media_paths_json AS mediaPathsJson, featured_rank AS featuredRank, owner_email AS ownerEmail, metadata_json AS metadataJson, created_at AS createdAt, updated_at AS updatedAt FROM pieces WHERE publication_status = 'published' ORDER BY featured_rank ASC, title ASC`; return (db.prepare(query).all() as Record[]).map(mapPiece); } export function getPiece(slug: string) { const db = getDatabase(); - const row = db.prepare(`SELECT slug, title, subtitle, category, status, publication_status AS publicationStatus, availability_label AS availabilityLabel, summary, story, details_json AS detailsJson, tags_json AS tagsJson, materials_json AS materialsJson, dimensions_json AS dimensionsJson, price_cents AS priceCents, inventory_count AS inventoryCount, lead_time_days AS leadTimeDays, media_paths_json AS mediaPathsJson, featured_rank AS featuredRank, owner_email AS ownerEmail, metadata_json AS metadataJson, created_at AS createdAt, updated_at AS updatedAt FROM pieces WHERE slug = ? LIMIT 1`).get(slug) as Record | undefined; + const row = db.prepare(`SELECT slug, title, subtitle, category, status, publication_status AS publicationStatus, availability_label AS availabilityLabel, summary, story, details_json AS detailsJson, tags_json AS tagsJson, materials_json AS materialsJson, dimensions_json AS dimensionsJson, price_cents AS priceCents, price_mode AS priceMode, public_price_label AS publicPriceLabel, internal_estimate_cents AS internalEstimateCents, inquiry_mode AS inquiryMode, reviews_mode AS reviewsMode, process_section_title AS processSectionTitle, process_section_intro AS processSectionIntro, visualizer_template AS visualizerTemplate, commission_type_slug AS commissionTypeSlug, inventory_count AS inventoryCount, lead_time_days AS leadTimeDays, media_paths_json AS mediaPathsJson, featured_rank AS featuredRank, owner_email AS ownerEmail, metadata_json AS metadataJson, created_at AS createdAt, updated_at AS updatedAt FROM pieces WHERE slug = ? LIMIT 1`).get(slug) as Record | undefined; return row ? mapPiece(row) : null; } +function synchronizeLegacyPieceMediaLinks(db: DatabaseSync, pieceSlug: string, mediaPaths: string[], metadata: Record) { + const paths = [...new Set(mediaPaths.map((value) => String(value).trim()).filter(Boolean))]; + const timestamp = nowIso(); + const isPublic = metadata.verifiedMedia !== false ? 1 : 0; + const exists = db.prepare("SELECT 1 AS present FROM media_items WHERE relative_path = ? LIMIT 1"); + + db.prepare("DELETE FROM piece_media_links WHERE piece_slug = ? AND legacy_synced = 1").run(pieceSlug); + const insert = db.prepare(` + INSERT OR IGNORE INTO piece_media_links ( + id, piece_slug, relative_path, role, stage, occurred_at, title, caption, + technical_note, alt_override, display_order, is_public, legacy_synced, created_at, updated_at + ) VALUES (?, ?, ?, ?, NULL, NULL, '', '', '', NULL, ?, ?, 1, ?, ?) + `); + + paths.forEach((relativePath, index) => { + if (!exists.get(relativePath)) return; + insert.run(randomUUID(), pieceSlug, relativePath, index === 0 ? "hero" : "gallery", index, isPublic, timestamp, timestamp); + }); +} + export function savePiece(input: Omit) { - const db = getDatabase(); + return withDatabaseTransaction((db) => { const existing = getPiece(input.slug); const timestamp = nowIso(); + const priceMode = normalizePriceMode(input.priceMode ?? input.metadata.priceMode, getPiecePriceMode(input)); + const inquiryMode = normalizeInquiryMode(input.inquiryMode ?? input.metadata.inquiryMode, getPieceInquiryMode(input)); + const reviewsMode = normalizeReviewsMode(input.reviewsMode ?? input.metadata.reviewsMode, getPieceReviewsMode(input)); + const priceCents = priceMode === "fixed" && Number(input.priceCents) > 0 ? Math.round(Number(input.priceCents)) : null; + const publicPriceLabel = String(input.publicPriceLabel ?? input.metadata.publicPriceLabel ?? "").trim() || null; + const internalEstimateCents = input.internalEstimateCents == null + ? (Number.isInteger(Number(input.metadata.internalEstimateCents)) && Number(input.metadata.internalEstimateCents) >= 0 ? Number(input.metadata.internalEstimateCents) : null) + : Math.max(0, Math.round(Number(input.internalEstimateCents))); + const metadata = { + ...input.metadata, + priceMode, + inquiryMode, + reviewsMode, + ...(publicPriceLabel ? { publicPriceLabel } : {}), + ...(internalEstimateCents == null ? {} : { internalEstimateCents }) + }; clearSeedTombstone(db, "piece", input.slug); db.prepare(` - INSERT INTO pieces (slug, title, subtitle, category, status, publication_status, availability_label, summary, story, details_json, tags_json, materials_json, dimensions_json, price_cents, inventory_count, lead_time_days, media_paths_json, featured_rank, owner_email, metadata_json, created_at, updated_at) - VALUES (:slug, :title, :subtitle, :category, :status, :publicationStatus, :availabilityLabel, :summary, :story, :detailsJson, :tagsJson, :materialsJson, :dimensionsJson, :priceCents, :inventoryCount, :leadTimeDays, :mediaPathsJson, :featuredRank, :ownerEmail, :metadataJson, :createdAt, :updatedAt) + INSERT INTO pieces (slug, title, subtitle, category, status, publication_status, availability_label, summary, story, details_json, tags_json, materials_json, dimensions_json, price_cents, price_mode, public_price_label, internal_estimate_cents, inquiry_mode, reviews_mode, process_section_title, process_section_intro, visualizer_template, commission_type_slug, inventory_count, lead_time_days, media_paths_json, featured_rank, owner_email, metadata_json, created_at, updated_at) + VALUES (:slug, :title, :subtitle, :category, :status, :publicationStatus, :availabilityLabel, :summary, :story, :detailsJson, :tagsJson, :materialsJson, :dimensionsJson, :priceCents, :priceMode, :publicPriceLabel, :internalEstimateCents, :inquiryMode, :reviewsMode, :processSectionTitle, :processSectionIntro, :visualizerTemplate, :commissionTypeSlug, :inventoryCount, :leadTimeDays, :mediaPathsJson, :featuredRank, :ownerEmail, :metadataJson, :createdAt, :updatedAt) ON CONFLICT(slug) DO UPDATE SET title = excluded.title, subtitle = excluded.subtitle, @@ -1809,6 +1982,15 @@ export function savePiece(input: Omit) { materials_json = excluded.materials_json, dimensions_json = excluded.dimensions_json, price_cents = excluded.price_cents, + price_mode = excluded.price_mode, + public_price_label = excluded.public_price_label, + internal_estimate_cents = excluded.internal_estimate_cents, + inquiry_mode = excluded.inquiry_mode, + reviews_mode = excluded.reviews_mode, + process_section_title = excluded.process_section_title, + process_section_intro = excluded.process_section_intro, + visualizer_template = excluded.visualizer_template, + commission_type_slug = excluded.commission_type_slug, inventory_count = excluded.inventory_count, lead_time_days = excluded.lead_time_days, media_paths_json = excluded.media_paths_json, @@ -1830,23 +2012,190 @@ export function savePiece(input: Omit) { tagsJson: writeJson(input.tags), materialsJson: writeJson(input.materials), dimensionsJson: writeJson(input.dimensions), - priceCents: input.priceCents, + priceCents, + priceMode, + publicPriceLabel, + internalEstimateCents, + inquiryMode, + reviewsMode, + processSectionTitle: String(input.processSectionTitle ?? input.metadata.processSectionTitle ?? "Build record").trim() || "Build record", + processSectionIntro: String(input.processSectionIntro ?? input.metadata.processSectionIntro ?? ""), + visualizerTemplate: String(input.visualizerTemplate ?? input.metadata.visualizerTemplate ?? "").trim() || null, + commissionTypeSlug: String(input.commissionTypeSlug ?? input.metadata.commissionTypeSlug ?? "").trim() || null, inventoryCount: input.inventoryCount, leadTimeDays: input.leadTimeDays, mediaPathsJson: writeJson(input.mediaPaths), featuredRank: input.featuredRank, ownerEmail: input.ownerEmail ?? null, - metadataJson: writeJson(input.metadata), + metadataJson: writeJson(metadata), createdAt: existing?.createdAt ?? timestamp, updatedAt: timestamp }); + synchronizeLegacyPieceMediaLinks(db, input.slug, input.mediaPaths, metadata); + }); } export function deletePiece(slug: string) { + withDatabaseTransaction((db) => { + db.prepare(`DELETE FROM pieces WHERE slug = ?`).run(slug); + db.prepare(`UPDATE media_items SET piece_slug = NULL WHERE piece_slug = ?`).run(slug); + recordSeedTombstone(db, "piece", slug); + }); +} + +function mapPieceMediaLink(row: Record): PieceMediaLinkRecord { + return { + id: String(row.id), + pieceSlug: String(row.pieceSlug), + relativePath: String(row.relativePath), + role: row.role as PieceMediaRole, + stage: row.stage ? String(row.stage) : null, + occurredAt: row.occurredAt ? String(row.occurredAt) : null, + title: String(row.title ?? ""), + caption: String(row.caption ?? ""), + technicalNote: String(row.technicalNote ?? ""), + altOverride: row.altOverride ? String(row.altOverride) : null, + displayOrder: Number(row.displayOrder ?? 0), + public: toBoolean(row.public), + legacySynced: toBoolean(row.legacySynced), + createdAt: String(row.createdAt), + updatedAt: String(row.updatedAt) + }; +} + +export function listPieceMediaLinks(pieceSlug: string, options: { publicOnly?: boolean; roles?: PieceMediaRole[] } = {}) { const db = getDatabase(); - db.prepare(`DELETE FROM pieces WHERE slug = ?`).run(slug); - db.prepare(`UPDATE media_items SET piece_slug = NULL WHERE piece_slug = ?`).run(slug); - recordSeedTombstone(db, "piece", slug); + const clauses = ["piece_slug = ?"]; + const params: Array = [pieceSlug]; + if (options.publicOnly) clauses.push("is_public = 1"); + if (options.roles?.length) { + clauses.push(`role IN (${options.roles.map(() => "?").join(", ")})`); + params.push(...options.roles); + } + const rows = db.prepare(` + SELECT id, piece_slug AS pieceSlug, relative_path AS relativePath, role, stage, + occurred_at AS occurredAt, title, caption, technical_note AS technicalNote, + alt_override AS altOverride, display_order AS displayOrder, is_public AS public, + legacy_synced AS legacySynced, created_at AS createdAt, updated_at AS updatedAt + FROM piece_media_links + WHERE ${clauses.join(" AND ")} + ORDER BY CASE role WHEN 'hero' THEN 0 WHEN 'gallery' THEN 1 WHEN 'detail' THEN 2 WHEN 'context' THEN 3 ELSE 4 END, + display_order ASC, created_at ASC + `).all(...params) as Record[]; + return rows.map(mapPieceMediaLink); +} + +export type PieceMediaLinkInput = Omit & { + id?: string; +}; + +export function replacePieceMediaLinks(pieceSlug: string, links: PieceMediaLinkInput[], actorEmail: string | null = null) { + return withDatabaseTransaction((db) => { + if (!getPiece(pieceSlug)) throw new Error(`Piece '${pieceSlug}' does not exist.`); + const before = listPieceMediaLinks(pieceSlug); + const mediaExists = db.prepare("SELECT 1 AS present FROM media_items WHERE relative_path = ? LIMIT 1"); + const seen = new Set(); + for (const link of links) { + if (!PIECE_MEDIA_ROLES.includes(link.role)) throw new Error(`Unsupported piece media role '${link.role}'.`); + if (!mediaExists.get(link.relativePath)) throw new Error(`Media '${link.relativePath}' does not exist.`); + const identity = `${link.relativePath}\u0000${link.role}\u0000${link.stage ?? ""}`; + if (seen.has(identity)) throw new Error(`Duplicate media role '${link.role}' for '${link.relativePath}'.`); + seen.add(identity); + } + + db.prepare("DELETE FROM piece_media_links WHERE piece_slug = ?").run(pieceSlug); + const insert = db.prepare(` + INSERT INTO piece_media_links ( + id, piece_slug, relative_path, role, stage, occurred_at, title, caption, + technical_note, alt_override, display_order, is_public, legacy_synced, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?) + `); + const timestamp = nowIso(); + links.forEach((link, index) => { + insert.run( + link.id || randomUUID(), pieceSlug, link.relativePath, link.role, link.stage ?? null, + link.occurredAt ?? null, link.title ?? "", link.caption ?? "", link.technicalNote ?? "", + link.altOverride ?? null, Number.isFinite(link.displayOrder) ? Math.round(link.displayOrder) : index, + link.public ? 1 : 0, timestamp, timestamp + ); + db.prepare("UPDATE media_items SET piece_slug = COALESCE(piece_slug, ?), updated_at = ? WHERE relative_path = ?") + .run(pieceSlug, timestamp, link.relativePath); + }); + + const legacyPaths = links + .filter((link) => link.public && ["hero", "gallery", "detail", "context"].includes(link.role)) + .sort((left, right) => (left.role === "hero" ? -1 : right.role === "hero" ? 1 : left.displayOrder - right.displayOrder)) + .map((link) => link.relativePath); + db.prepare("UPDATE pieces SET media_paths_json = ?, updated_at = ? WHERE slug = ?") + .run(writeJson([...new Set(legacyPaths)]), timestamp, pieceSlug); + + const after = listPieceMediaLinks(pieceSlug); + recordAdminEditAudit({ actorEmail, entityType: "piece-media", entityKey: pieceSlug, operation: "replace", before, after }); + return after; + }); +} + +export function recordAdminEditAudit(input: { + actorEmail?: string | null; + entityType: string; + entityKey: string; + operation: string; + before?: unknown; + after?: unknown; + requestId?: string | null; + revertedById?: string | null; +}) { + const db = getDatabase(); + const id = randomUUID(); + db.prepare(` + INSERT INTO admin_edit_audit ( + id, actor_email, entity_type, entity_key, operation, before_json, after_json, + request_id, reverted_by_id, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + id, + input.actorEmail?.toLowerCase() ?? null, + input.entityType, + input.entityKey, + input.operation, + writeJson(input.before ?? null), + writeJson(input.after ?? null), + input.requestId ?? null, + input.revertedById ?? null, + nowIso() + ); + return id; +} + +export function listAdminEditAudit(options: { entityType?: string; entityKey?: string; limit?: number } = {}) { + const db = getDatabase(); + const clauses: string[] = []; + const params: Array = []; + if (options.entityType) { clauses.push("entity_type = ?"); params.push(options.entityType); } + if (options.entityKey) { clauses.push("entity_key = ?"); params.push(options.entityKey); } + const limit = Math.max(1, Math.min(250, Math.round(options.limit ?? 50))); + params.push(limit); + const rows = db.prepare(` + SELECT id, actor_email AS actorEmail, entity_type AS entityType, entity_key AS entityKey, + operation, before_json AS beforeJson, after_json AS afterJson, request_id AS requestId, + reverted_by_id AS revertedById, created_at AS createdAt + FROM admin_edit_audit + ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""} + ORDER BY created_at DESC + LIMIT ? + `).all(...params) as Record[]; + return rows.map((row): AdminEditAuditRecord => ({ + id: String(row.id), + actorEmail: row.actorEmail ? String(row.actorEmail) : null, + entityType: String(row.entityType), + entityKey: String(row.entityKey), + operation: String(row.operation), + before: readJson(row.beforeJson, null), + after: readJson(row.afterJson, null), + requestId: row.requestId ? String(row.requestId) : null, + revertedById: row.revertedById ? String(row.revertedById) : null, + createdAt: String(row.createdAt) + })); } export function listPosts(includeDraft = false) { @@ -1961,9 +2310,14 @@ export function deleteCommissionType(slug: string) { function mediaJunkPathClauses() { return [ "lower(relative_path) NOT LIKE '%@eadir%'", + "lower(relative_path) NOT LIKE '%@synoeastream%'", + "lower(relative_path) NOT LIKE '%.woodsmith-trash%'", "lower(relative_path) NOT LIKE '%synofile_thumb%'", "lower(file_name) NOT IN ('synoindex_media_info', '.ds_store', 'thumbs.db')", - "lower(file_name) NOT LIKE '._%'" + "lower(file_name) NOT LIKE '._%'", + "lower(file_name) NOT LIKE 'synophoto_%'", + "lower(file_name) NOT LIKE 'synoindex_%'", + "lower(file_name) NOT LIKE '~rf%'" ]; } @@ -2159,7 +2513,67 @@ function replaceMediaPathInList(values: string[], previousPath: string, nextPath return [...new Set(nextValues)]; } -function rewriteMediaReferences(previousPath: string, nextPath: string | null) { +function replaceMediaPathDeep(value: unknown, previousPath: string, nextPath: string | null): unknown { + if (typeof value === "string") return value === previousPath ? nextPath : value; + if (Array.isArray(value)) { + return value + .map((entry) => replaceMediaPathDeep(entry, previousPath, nextPath)) + .filter((entry) => entry !== null); + } + if (value && typeof value === "object") { + return Object.fromEntries(Object.entries(value).flatMap(([key, entry]) => { + const replaced = replaceMediaPathDeep(entry, previousPath, nextPath); + return replaced === null ? [] : [[key, replaced]]; + })); + } + return value; +} + +function rewriteJsonReferences(db: DatabaseSync, previousPath: string, nextPath: string | null) { + const specs = [ + { table: "settings", key: "key", columns: ["value"] }, + { table: "projects", key: "reference", columns: ["estimator_json", "options_json", "shipping_address_json", "billing_address_json"] }, + { table: "project_updates", key: "id", columns: ["attachments_json"] }, + { table: "media_items", key: "relative_path", columns: ["metadata_json"] }, + { table: "embedding_cache", key: "key", columns: ["metadata_json"] } + ] as const; + + for (const spec of specs) { + const rows = db.prepare(`SELECT ${spec.key} AS rowKey, ${spec.columns.join(", ")} FROM ${spec.table}`).all() as Record[]; + for (const row of rows) { + for (const column of spec.columns) { + const current = readJson(row[column], null); + const next = replaceMediaPathDeep(current, previousPath, nextPath); + if (writeJson(next) === writeJson(current)) continue; + db.prepare(`UPDATE ${spec.table} SET ${column} = ? WHERE ${spec.key} = ?`).run(writeJson(next), String(row.rowKey)); + } + } + } +} + +function rewritePieceMediaLinkPaths(db: DatabaseSync, previousPath: string, nextPath: string | null) { + if (!nextPath) { + db.prepare("DELETE FROM piece_media_links WHERE relative_path = ?").run(previousPath); + return; + } + + const rows = db.prepare(` + SELECT id, piece_slug AS pieceSlug, role, stage + FROM piece_media_links + WHERE relative_path = ? + `).all(previousPath) as Array>; + for (const row of rows) { + const duplicate = db.prepare(` + SELECT id FROM piece_media_links + WHERE piece_slug = ? AND relative_path = ? AND role = ? AND IFNULL(stage, '') = IFNULL(?, '') + LIMIT 1 + `).get(String(row.pieceSlug), nextPath, String(row.role), row.stage == null ? null : String(row.stage)) as { id?: unknown } | undefined; + if (duplicate) db.prepare("DELETE FROM piece_media_links WHERE id = ?").run(String(row.id)); + else db.prepare("UPDATE piece_media_links SET relative_path = ?, updated_at = ? WHERE id = ?").run(nextPath, nowIso(), String(row.id)); + } +} + +function rewriteMediaReferences(db: DatabaseSync, previousPath: string, nextPath: string | null) { const affectedPieceSlugs: string[] = []; const affectedPostSlugs: string[] = []; const affectedPageSlugs: string[] = []; @@ -2167,38 +2581,31 @@ function rewriteMediaReferences(previousPath: string, nextPath: string | null) { for (const piece of listPieces(true)) { if (!piece.mediaPaths.includes(previousPath)) continue; const nextMediaPaths = replaceMediaPathInList(piece.mediaPaths, previousPath, nextPath); - savePiece({ ...piece, mediaPaths: nextMediaPaths }); + db.prepare("UPDATE pieces SET media_paths_json = ?, updated_at = ? WHERE slug = ?").run(writeJson(nextMediaPaths), nowIso(), piece.slug); affectedPieceSlugs.push(piece.slug); } + rewritePieceMediaLinkPaths(db, previousPath, nextPath); + for (const post of listPosts(true)) { if (post.coverMediaPath !== previousPath) continue; - savePost({ ...post, coverMediaPath: nextPath }); + db.prepare("UPDATE posts SET cover_media_path = ?, updated_at = ? WHERE slug = ?").run(nextPath, nowIso(), post.slug); affectedPostSlugs.push(post.slug); } for (const page of listPages(true)) { if (page.heroMediaPath !== previousPath) continue; - savePage({ ...page, heroMediaPath: nextPath }); + db.prepare("UPDATE pages SET hero_media_path = ?, updated_at = ? WHERE slug = ?").run(nextPath, nowIso(), page.slug); affectedPageSlugs.push(page.slug); } for (const user of listUsers()) { if (user.avatarPath !== previousPath) continue; - saveUserProfile({ - originalEmail: user.email, - email: user.email, - role: user.role, - displayName: user.displayName, - headline: user.headline, - bio: user.bio, - avatarPath: nextPath, - publicProfile: user.publicProfile, - links: user.links, - metadata: user.metadata - }); + db.prepare("UPDATE users SET avatar_path = ?, updated_at = ? WHERE lower(email) = lower(?)").run(nextPath, nowIso(), user.email); } + rewriteJsonReferences(db, previousPath, nextPath); + return { pieceSlugs: [...new Set(affectedPieceSlugs)], postSlugs: [...new Set(affectedPostSlugs)], @@ -2206,17 +2613,54 @@ function rewriteMediaReferences(previousPath: string, nextPath: string | null) { }; } -export function renameMediaRecordAndReferences(previousPath: string, nextPath: string) { +export function startMediaRenameHistory(previousPath: string, nextPath: string | null, actorEmail: string | null = null, status: MediaRenameHistoryRecord["status"] = "planned") { + const db = getDatabase(); + const id = randomUUID(); + db.prepare(` + INSERT INTO media_rename_history (id, previous_path, next_path, status, actor_email, error, rollback_of, created_at, completed_at) + VALUES (?, ?, ?, ?, ?, NULL, NULL, ?, ?) + `).run(id, previousPath, nextPath, status, actorEmail?.toLowerCase() ?? null, nowIso(), status === "planned" ? null : nowIso()); + return id; +} + +export function finishMediaRenameHistory(id: string, status: MediaRenameHistoryRecord["status"], error: string | null = null) { + const db = getDatabase(); + db.prepare("UPDATE media_rename_history SET status = ?, error = ?, completed_at = ? WHERE id = ?") + .run(status, error, nowIso(), id); +} + +export function listMediaRenameHistory(limit = 100) { + const db = getDatabase(); + const rows = db.prepare(` + SELECT id, previous_path AS previousPath, next_path AS nextPath, status, actor_email AS actorEmail, + error, rollback_of AS rollbackOf, created_at AS createdAt, completed_at AS completedAt + FROM media_rename_history ORDER BY created_at DESC LIMIT ? + `).all(Math.max(1, Math.min(500, Math.round(limit)))) as Record[]; + return rows.map((row): MediaRenameHistoryRecord => ({ + id: String(row.id), + previousPath: String(row.previousPath), + nextPath: row.nextPath ? String(row.nextPath) : null, + status: row.status as MediaRenameHistoryRecord["status"], + actorEmail: row.actorEmail ? String(row.actorEmail) : null, + error: row.error ? String(row.error) : null, + rollbackOf: row.rollbackOf ? String(row.rollbackOf) : null, + createdAt: String(row.createdAt), + completedAt: row.completedAt ? String(row.completedAt) : null + })); +} + +export function renameMediaRecordAndReferences(previousPath: string, nextPath: string, options: { actorEmail?: string | null; historyId?: string | null } = {}) { if (previousPath === nextPath) { return { pieceSlugs: [], postSlugs: [], pageSlugs: [] }; } - const db = getDatabase(); - const previous = getMedia(previousPath); - - syncMediaLibraryIntoDatabase(db); + const historyId = options.historyId ?? startMediaRenameHistory(previousPath, nextPath, options.actorEmail ?? null); + try { + const affected = withDatabaseTransaction((db) => { + const previous = getMedia(previousPath); + syncMediaLibraryIntoDatabase(db); + if (!getMedia(nextPath)) throw new Error(`Renamed media '${nextPath}' was not found during reference synchronization.`); - if (previous) { - saveMediaMetadata({ + if (previous) saveMediaMetadata({ relativePath: nextPath, altText: previous.altText, pieceSlug: previous.pieceSlug, @@ -2230,18 +2674,32 @@ export function renameMediaRecordAndReferences(previousPath: string, nextPath: s reviewed: previous.reviewed, tags: previous.tags, metadata: previous.metadata + }); + + const result = rewriteMediaReferences(db, previousPath, nextPath); + db.prepare(`DELETE FROM media_items WHERE relative_path = ?`).run(previousPath); + return result; }); + finishMediaRenameHistory(historyId, "completed"); + return affected; + } catch (error) { + finishMediaRenameHistory(historyId, "failed", error instanceof Error ? error.message : String(error)); + throw error; } - - db.prepare(`DELETE FROM media_items WHERE relative_path = ?`).run(previousPath); - return rewriteMediaReferences(previousPath, nextPath); } -export function deleteMediaRecordAndReferences(relativePath: string) { - const db = getDatabase(); - const affected = rewriteMediaReferences(relativePath, null); - db.prepare(`DELETE FROM media_items WHERE relative_path = ?`).run(relativePath); - return affected; +export function deleteMediaRecordAndReferences(relativePath: string, actorEmail: string | null = null) { + const historyId = startMediaRenameHistory(relativePath, null, actorEmail, "deleted"); + try { + return withDatabaseTransaction((db) => { + const affected = rewriteMediaReferences(db, relativePath, null); + db.prepare(`DELETE FROM media_items WHERE relative_path = ?`).run(relativePath); + return affected; + }); + } catch (error) { + finishMediaRenameHistory(historyId, "failed", error instanceof Error ? error.message : String(error)); + throw error; + } } export function refreshMediaLibrary() { diff --git a/site/lib/media-reference-transaction.test.mts b/site/lib/media-reference-transaction.test.mts new file mode 100644 index 0000000..f3cc671 --- /dev/null +++ b/site/lib/media-reference-transaction.test.mts @@ -0,0 +1,77 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +test("media rename rewrites legacy and normalized references transactionally", async () => { + const root = mkdtempSync(path.join(tmpdir(), "woodsmith-reference-")); + const dataRoot = path.join(root, "data"); + const mediaRoot = path.join(root, "media"); + const originalPath = "Furniture/test-piece/original.jpg"; + const nextPath = "Furniture/test-piece/renamed.jpg"; + mkdirSync(path.join(mediaRoot, "Furniture", "test-piece"), { recursive: true }); + writeFileSync(path.join(mediaRoot, ...originalPath.split("/")), Buffer.from("fixture")); + process.env.NODE_ENV = "test"; + process.env.DATA_ROOT = dataRoot; + process.env.MEDIA_ROOT = mediaRoot; + + const db = await import("./db.ts"); + const media = await import("./media.ts"); + try { + db.getRuntimePersistenceStatus(); + db.savePiece({ + slug: "test-piece", + title: "Test Piece", + subtitle: "", + category: "Objects", + status: "archive", + publicationStatus: "published", + availabilityLabel: "Unavailable", + summary: "", + story: "", + details: [], + tags: [], + materials: [], + dimensions: null, + priceCents: null, + priceMode: "not-listed", + inquiryMode: "disabled", + reviewsMode: "hidden", + inventoryCount: 0, + leadTimeDays: 0, + mediaPaths: [originalPath], + featuredRank: 999, + ownerEmail: null, + metadata: { verifiedMedia: true } + }); + db.savePage({ + slug: "rename-fixture", + title: "Rename fixture", + navLabel: "Rename fixture", + status: "draft", + intro: "", + body: "", + layout: "document", + sections: [], + heroMediaPath: originalPath + }); + + const historyId = db.startMediaRenameHistory(originalPath, nextPath, "admin@example.com"); + media.moveMediaAsset(originalPath, nextPath); + const affected = db.renameMediaRecordAndReferences(originalPath, nextPath, { actorEmail: "admin@example.com", historyId }); + + assert.deepEqual(affected.pieceSlugs, ["test-piece"]); + assert.deepEqual(affected.pageSlugs, ["rename-fixture"]); + assert.equal(existsSync(path.join(mediaRoot, ...originalPath.split("/"))), false); + assert.equal(existsSync(path.join(mediaRoot, ...nextPath.split("/"))), true); + assert.deepEqual(db.getPiece("test-piece")?.mediaPaths, [nextPath]); + assert.deepEqual(db.listPieceMediaLinks("test-piece").map((link) => link.relativePath), [nextPath]); + assert.equal(db.getPage("rename-fixture")?.heroMediaPath, nextPath); + assert.equal(db.listMediaRenameHistory(1)[0]?.status, "completed"); + assert.equal(db.getRuntimePersistenceStatus().quickCheck, "ok"); + } finally { + db.closeDatabaseForTests(); + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/site/lib/media.ts b/site/lib/media.ts index edfb12f..dda4168 100644 --- a/site/lib/media.ts +++ b/site/lib/media.ts @@ -4,7 +4,7 @@ import { randomUUID } from "node:crypto"; const IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp", ".avif", ".gif", ".svg", ".bmp", ".heic", ".heif", ".tif", ".tiff"]); const VIDEO_EXTENSIONS = new Set([".mp4", ".mov", ".m4v", ".webm"]); -const IGNORED_MEDIA_FILE_NAMES = new Set(["synoindex_media_info", ".ds_store", "thumbs.db"]); +const IGNORED_MEDIA_FILE_NAMES = new Set(["synoindex_media_info", ".ds_store", "thumbs.db"]); const DEFAULT_MEDIA_ROOT = "/app/pics"; export type MediaKind = "image" | "video" | "other"; @@ -62,15 +62,15 @@ export function detectMediaKind(fileName: string): MediaKind { return "other"; } -function shouldIgnoreMediaEntry(name: string) { - const normalized = name.toLowerCase(); - if (IGNORED_MEDIA_FILE_NAMES.has(normalized) || normalized.startsWith("._")) { - return true; - } - if (normalized === "@eadir") { - return true; - } - if (normalized.includes("synofile_thumb")) { +function shouldIgnoreMediaEntry(name: string) { + const normalized = name.toLowerCase(); + if (IGNORED_MEDIA_FILE_NAMES.has(normalized) || normalized.startsWith("._") || normalized.startsWith("~rf")) { + return true; + } + if (normalized === "@eadir" || normalized === "@synoeastream" || normalized === ".woodsmith-trash") { + return true; + } + if (normalized.startsWith("synophoto_") || normalized.startsWith("synoindex_") || normalized.includes("synofile_thumb")) { return true; } return false; @@ -136,7 +136,7 @@ function walkMedia(directory: string, root: string, output: MediaScanRecord[]) { const stats = statSync(absolutePath); const relativePath = normalizeRelativePath(path.relative(root, absolutePath)); const rpLower = relativePath.toLowerCase(); - if (rpLower.includes("@eadir") || rpLower.includes("synofile_thumb")) { + if (rpLower.includes("@eadir") || rpLower.includes("@synoeastream") || rpLower.includes("/.woodsmith-trash/") || rpLower.includes("synofile_thumb") || /(^|\/)synophoto_/i.test(relativePath) || /(^|\/)synoindex_/i.test(relativePath)) { continue; } @@ -204,6 +204,11 @@ export function persistGeneratedMedia(base64Image: string, folder = "generated", } export function renameMediaAsset(relativePath: string, nextBaseName: string) { + const nextPath = previewMediaRenamePath(relativePath, nextBaseName); + return moveMediaAsset(relativePath, nextPath); +} + +export function previewMediaRenamePath(relativePath: string, nextBaseName: string) { const currentAbsolutePath = resolveMediaPath(relativePath); const parsed = path.parse(currentAbsolutePath); const baseName = slugify(nextBaseName) || `media-${randomUUID().slice(0, 8)}`; @@ -215,9 +220,34 @@ export function renameMediaAsset(relativePath: string, nextBaseName: string) { if (existsSync(targetAbsolutePath)) { throw new Error(`A media file named '${path.basename(targetAbsolutePath)}' already exists in this folder.`); } - renameSync(currentAbsolutePath, targetAbsolutePath); return normalizeRelativePath(path.relative(mediaRoot, targetAbsolutePath)); } + +export function moveMediaAsset(relativePath: string, nextRelativePath: string) { + const currentAbsolutePath = resolveMediaPath(relativePath); + const targetAbsolutePath = resolveMediaPath(nextRelativePath); + if (path.resolve(targetAbsolutePath) === path.resolve(currentAbsolutePath)) return normalizeRelativePath(nextRelativePath); + if (existsSync(targetAbsolutePath)) throw new Error(`A media file named '${path.basename(targetAbsolutePath)}' already exists in this folder.`); + mkdirSync(path.dirname(targetAbsolutePath), { recursive: true }); + renameSync(currentAbsolutePath, targetAbsolutePath); + return normalizeRelativePath(nextRelativePath); +} + +export function stageMediaAssetDeletion(relativePath: string) { + const parsed = path.posix.parse(normalizeRelativePath(relativePath)); + const stagedPath = `.woodsmith-trash/${randomUUID()}-${parsed.base}`; + moveMediaAsset(relativePath, stagedPath); + return { originalPath: normalizeRelativePath(relativePath), stagedPath }; +} + +export function restoreStagedMediaAsset(input: { originalPath: string; stagedPath: string }) { + return moveMediaAsset(input.stagedPath, input.originalPath); +} + +export function finalizeStagedMediaDeletion(input: { stagedPath: string }) { + const absolutePath = resolveMediaPath(input.stagedPath); + rmSync(absolutePath, { force: true }); +} export function deleteMediaAsset(relativePath: string) { const absolutePath = resolveMediaPath(relativePath); diff --git a/site/lib/piece-model.test.mts b/site/lib/piece-model.test.mts new file mode 100644 index 0000000..0a980d5 --- /dev/null +++ b/site/lib/piece-model.test.mts @@ -0,0 +1,63 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + getPieceInquiryMode, + getPiecePriceMode, + getPiecePublicPriceLabel, + getPieceReviewsMode, + pieceAcceptsReviews, + pieceAllowsInquiry, + pieceCanEnterCart, + pieceDisplaysReviews, + type PiecePolicySource, + type PriceMode +} from "./piece-model.ts"; + +function piece(overrides: Partial = {}): PiecePolicySource { + return { + status: "inventory", + publicationStatus: "published", + availabilityLabel: "Available", + priceCents: 125_000, + inventoryCount: 1, + metadata: {}, + ...overrides + }; +} + +test("fixed-price cart eligibility requires public positive inventory", () => { + assert.equal(pieceCanEnterCart(piece({ priceMode: "fixed" })), true); + assert.equal(pieceCanEnterCart(piece({ priceMode: "fixed", priceCents: 0 })), false); + assert.equal(pieceCanEnterCart(piece({ priceMode: "fixed", inventoryCount: 0 })), false); + assert.equal(pieceCanEnterCart(piece({ priceMode: "contact-for-price" })), false); + assert.equal(pieceCanEnterCart(piece({ priceMode: "fixed", publicationStatus: "draft" })), false); +}); + +test("all pricing modes resolve without interpreting sentinels as money", () => { + const modes: PriceMode[] = [ + "fixed", + "not-listed", + "contact-for-price", + "determined-after-approval", + "determined-at-order-completion" + ]; + for (const mode of modes) assert.equal(getPiecePriceMode(piece({ priceMode: mode })), mode); + assert.equal(getPiecePriceMode(piece({ status: "archive", priceCents: -1, priceMode: null })), "not-listed"); + assert.equal(getPiecePriceMode(piece({ status: "inventory", priceCents: -1, priceMode: null })), "contact-for-price"); + assert.equal(getPiecePublicPriceLabel(piece({ priceMode: "contact-for-price" })), "Contact for price"); + assert.equal(getPiecePublicPriceLabel(piece({ priceMode: "not-listed" })), null); +}); + +test("inquiry and review modes are independent from legacy status", () => { + const source = piece({ inquiryMode: "disabled", reviewsMode: "hidden" }); + assert.equal(getPieceInquiryMode(source), "disabled"); + assert.equal(pieceAllowsInquiry(source), false); + assert.equal(getPieceReviewsMode(source), "hidden"); + assert.equal(pieceDisplaysReviews(source), false); + assert.equal(pieceAcceptsReviews(source), false); + + const enabled = piece({ inquiryMode: "related-commission", reviewsMode: "display-and-accept" }); + assert.equal(pieceAllowsInquiry(enabled), true); + assert.equal(pieceDisplaysReviews(enabled), true); + assert.equal(pieceAcceptsReviews(enabled), true); +}); diff --git a/site/lib/piece-model.ts b/site/lib/piece-model.ts new file mode 100644 index 0000000..19bc79a --- /dev/null +++ b/site/lib/piece-model.ts @@ -0,0 +1,125 @@ +export const PRICE_MODES = [ + "fixed", + "not-listed", + "contact-for-price", + "determined-after-approval", + "determined-at-order-completion" +] as const; + +export type PriceMode = (typeof PRICE_MODES)[number]; + +export const INQUIRY_MODES = [ + "disabled", + "exact-piece", + "custom-pattern", + "related-commission" +] as const; + +export type InquiryMode = (typeof INQUIRY_MODES)[number]; + +export const REVIEWS_MODES = [ + "hidden", + "display-approved", + "display-and-accept" +] as const; + +export type ReviewsMode = (typeof REVIEWS_MODES)[number]; + +export type PiecePolicySource = { + status: "inventory" | "commission" | "archive"; + publicationStatus?: "published" | "draft" | "archived"; + availabilityLabel?: string; + priceCents: number | null; + priceMode?: PriceMode | null; + publicPriceLabel?: string | null; + inquiryMode?: InquiryMode | null; + reviewsMode?: ReviewsMode | null; + inventoryCount?: number; + metadata?: Record; +}; + +function oneOf(value: unknown, values: T): value is T[number] { + return typeof value === "string" && (values as readonly string[]).includes(value); +} + +export function normalizePriceMode(value: unknown, fallback: PriceMode = "not-listed"): PriceMode { + return oneOf(value, PRICE_MODES) ? value : fallback; +} + +export function normalizeInquiryMode(value: unknown, fallback: InquiryMode = "disabled"): InquiryMode { + return oneOf(value, INQUIRY_MODES) ? value : fallback; +} + +export function normalizeReviewsMode(value: unknown, fallback: ReviewsMode = "hidden"): ReviewsMode { + return oneOf(value, REVIEWS_MODES) ? value : fallback; +} + +export function inferLegacyPriceMode(source: Pick): PriceMode { + if (typeof source.priceCents === "number" && source.priceCents > 0) return "fixed"; + if (source.status === "commission") return "determined-after-approval"; + + const availability = String(source.availabilityLabel ?? "").toLowerCase(); + if (source.status === "inventory" && /(available|request|inquir|ask|contact)/.test(availability)) { + return "contact-for-price"; + } + + return "not-listed"; +} + +export function inferLegacyInquiryMode(source: Pick): InquiryMode { + if (source.publicationStatus === "archived") return "disabled"; + if (source.status === "inventory") return "exact-piece"; + if (source.status === "commission") return "custom-pattern"; + return source.publicationStatus === "published" ? "related-commission" : "disabled"; +} + +export function inferLegacyReviewsMode(source: Pick): ReviewsMode { + return source.publicationStatus === "published" ? "display-and-accept" : "hidden"; +} + +export function getPiecePriceMode(piece: PiecePolicySource): PriceMode { + const explicit = piece.priceMode ?? piece.metadata?.priceMode; + return normalizePriceMode(explicit, inferLegacyPriceMode(piece)); +} + +export function getPieceInquiryMode(piece: PiecePolicySource): InquiryMode { + const explicit = piece.inquiryMode ?? piece.metadata?.inquiryMode; + return normalizeInquiryMode(explicit, inferLegacyInquiryMode(piece)); +} + +export function getPieceReviewsMode(piece: PiecePolicySource): ReviewsMode { + const explicit = piece.reviewsMode ?? piece.metadata?.reviewsMode; + return normalizeReviewsMode(explicit, inferLegacyReviewsMode(piece)); +} + +export function getPiecePublicPriceLabel(piece: PiecePolicySource): string | null { + const override = String(piece.publicPriceLabel ?? piece.metadata?.publicPriceLabel ?? "").trim(); + if (override) return override; + + const mode = getPiecePriceMode(piece); + if (mode === "contact-for-price") return "Contact for price"; + if (mode === "determined-after-approval") return "Pricing follows design approval"; + if (mode === "determined-at-order-completion") return "Final price determined at completion"; + return null; +} + +export function pieceCanEnterCart(piece: PiecePolicySource): boolean { + return piece.publicationStatus === "published" + && piece.status === "inventory" + && getPiecePriceMode(piece) === "fixed" + && typeof piece.priceCents === "number" + && piece.priceCents > 0 + && Number(piece.inventoryCount ?? 0) > 0; +} + +export function pieceDisplaysReviews(piece: PiecePolicySource): boolean { + return getPieceReviewsMode(piece) !== "hidden"; +} + +export function pieceAcceptsReviews(piece: PiecePolicySource): boolean { + return getPieceReviewsMode(piece) === "display-and-accept"; +} + +export function pieceAllowsInquiry(piece: PiecePolicySource): boolean { + return getPieceInquiryMode(piece) !== "disabled"; +} diff --git a/site/lib/seed.ts b/site/lib/seed.ts index ecfe966..3df66b9 100644 --- a/site/lib/seed.ts +++ b/site/lib/seed.ts @@ -633,4 +633,4 @@ export const seedProfiles: SeedProfile[] = [ metadata: { showOnAboutPage: true, developer: true } } ]; -import { defaultPieceCategories } from "@/lib/categories"; +import { defaultPieceCategories } from "./categories.ts"; diff --git a/site/package.json b/site/package.json index a479726..29dd8d8 100644 --- a/site/package.json +++ b/site/package.json @@ -8,7 +8,7 @@ "start": "node --experimental-sqlite .next/standalone/server.js", "lint": "eslint app components lib", "typecheck": "tsc --noEmit", - "test": "node --experimental-strip-types --test lib/media-scoring.test.mts" + "test": "node --experimental-strip-types --test lib/media-scoring.test.mts lib/piece-model.test.mts lib/database-migrations.test.mts lib/media-reference-transaction.test.mts" }, "dependencies": { "@react-three/drei": "^10.7.7", From 30c87f6564c52ed43de6871fbd61f890e5c8e82a Mon Sep 17 00:00:00 2001 From: Cooper Beaman <55124795+lowestprime@users.noreply.github.com> Date: Sat, 11 Jul 2026 05:57:57 -0700 Subject: [PATCH 02/43] docs: record sitewide UX and data audit --- docs/sitewide-ux-overhaul-audit-20260711.md | 178 ++++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 docs/sitewide-ux-overhaul-audit-20260711.md diff --git a/docs/sitewide-ux-overhaul-audit-20260711.md b/docs/sitewide-ux-overhaul-audit-20260711.md new file mode 100644 index 0000000..a6234ef --- /dev/null +++ b/docs/sitewide-ux-overhaul-audit-20260711.md @@ -0,0 +1,178 @@ +# Beaman Woodworks Sitewide UX and Data Audit + +Date: 2026-07-11 +Branch: `codex/sitewide-studio-ux-commission-overhaul-20260711` +Baseline commit: `81b35c4b97d464a388560c13c1526b78c10afa45` + +This document records the verified baseline used for the sitewide overhaul. Documentation claims were treated as hypotheses and checked against source, a disposable SQLite backup, supplied screenshots, and read-only production requests. + +## Evidence + +- Supplied screenshot 1, Studio category editor: 1773 x 1417 source pixels. +- Supplied screenshot 2, footer: 3703 x 1119 source pixels. +- Supplied screenshot 3, Portfolio filters: 3703 x 1247 source pixels. +- Supplied screenshot 4, home services/process section: 3703 x 2295 source pixels. +- Production origin checked read-only: `https://woodmat.ch`. +- Production headers reported Next.js behind Cloudflare with private/no-store HTML responses. +- Repository baseline: Next.js 16.2.10, React 19.2.0, TypeScript 5.8.3, ESLint 9.39.4, Node `node:sqlite`, Three.js 0.183.2, React Three Fiber 9.5.0, Drei 10.7.7. +- Disposable SQLite backup passed `PRAGMA quick_check` before and after migration testing. +- Source media inventory supplied by the user includes Synology `@eaDir`, `SYNOPHOTO_*`, `SYNOINDEX_*`, Motion Photo metadata, and recovery/temp artifacts that must never be surfaced. + +## Architecture Baseline + +| Area | Verified implementation | Risk or discrepancy | +| --- | --- | --- | +| Routing | Next App Router under `site/app`; dynamic public page, piece, media, request, and account routes | Route-level loading/error coverage is inconsistent | +| Rendering | Server components by default; client components for theme, header behavior, inline editing, media desk, lightbox, and visualizer | Global `force-dynamic` avoids stale SQLite content but increases repeated database work | +| Styling | `globals.css`, `refinements.css`, `brand-emblem.css`, then `ui-repair.css` | Repeated contracts and late `!important` rules make spacing and header behavior difficult to reason about | +| Persistence | SQLite at `DATA_ROOT/woodsmith.sqlite`, WAL mode, seed tombstones, direct NAS mount | Baseline had no schema migration ledger and startup seed-media synchronization could reverse manual decisions | +| Media | Direct writable `MEDIA_ROOT=/app/pics`, synchronous scan, SQLite metadata, paged Media Desk | Piece relations were duplicated between `pieces.media_paths_json` and `media_items.piece_slug`; no roles/stages | +| Authentication | Admin, woodworker, and customer roles; cookie-backed sessions; email verification/reset fields | Guest project access still relies on reference plus email in URL and requires a separate security slice | +| Inline editing | Public edit targets plus hard-coded API allowlists | Not typed, structural fields missing, multi-patch saves non-atomic, no durable audit trail at baseline | +| Commerce | Cart, totals, optional Stripe/EasyPost, graceful provider degradation | Nullable/negative prices could reach public/cart flows; no typed pricing policy at baseline | +| Commission preview | SVG and CSS pseudo-3D with estimator fields | Installed R3F/Drei/Three stack was unused; hidden fields were client-authoritative | +| Deployment | Synology Compose mounts data, media, and Next image cache; Cloudflare canonical origin | A container started outside authoritative Compose can still create disposable image-local data | + +## Route Matrix + +| Surface | Baseline state | Required correction | +| --- | --- | --- | +| `/` | Public hero, featured work, inert service cards, Process promotion | Linked services; commissioned-build section; compact responsive rhythm | +| `/portfolio` | Query-deep-linked filters and all published pieces | Compact icon rail, extensible icons, pagination/load-more, stable image sizing | +| `/portfolio/[slug]` | Gallery, unconditional inquiry and reviews, ambiguous price | Typed policy gating, normalized gallery, optional build record | +| `/shop` | Inventory pieces and unconditional reserve controls | Fixed-price-only cart controls and distinct inquiry-only work | +| `/shop/cart` | Silently filters null-priced items | Reject non-fixed entries at every server boundary and explain invalid lines | +| `/commissions` | One long contact form with CSS/SVG pseudo-3D | Primary multi-step journey and dynamically loaded real R3F preview with SVG fallback | +| `/commissions/status` | Reference/email lookup | Preserve behavior while replacing URL email capability in security slice | +| `/contact` | Duplicates commission form | Concise general contact and handoff to primary commission flow | +| `/about` | Public people/contact content | Preserve credits, use structured editable contact/footer models | +| `/care-and-warranty` | Dynamic page | Preserve and make structural content editable | +| `/search` | Public/private search boundary | Preserve authorization; add policy-aware labels and normalized media metadata | +| `/process`, `/process/[slug]` | Optional archive | Retain archive, remove home promotion, separate process media from finished galleries | +| `/account/*` | Signup, login, verify, forgot/reset, profile, projects | Preserve; later harden draft/resume and guest project access | +| `/requests/[reference]` | Buyer/project dossier | Preserve privacy checks; add high-entropy guest capability in security slice | +| `/studio/login` | Admin entry | Preserve noindex and session protections | +| `/studio` | One panel at a time, but many panels mount every editor expanded | Master-detail editors, in-place saves, visual media relations, consistent dirty/saved states | +| `/media/[...slug]` | Full-resolution mounted media route | Reject all sidecar/temp paths even when directly requested | +| 404/loading/error | Custom 404 plus generic loading | Add route-level actionable error states and preserve compact shell | + +## Studio Panel Matrix + +| Panel | Baseline finding | Priority | +| --- | --- | --- | +| Overview | Useful persistence and provider summary; oversized/redundant metrics | Medium | +| Settings | Partial brand/home fields only | High | +| Pages | Every page fully expanded; raw hero path | Critical | +| Pieces | Every piece fully expanded; raw gallery paths; no typed public policy controls | Critical | +| Categories | Five-value native icon select; no order/visibility/custom SVG | Critical | +| Custom | Commission types lack category/template/range constraints | High | +| People | Raw avatar path in Studio | High | +| Process | Raw cover path and long editor list | High | +| Media | Strong in-place three-pane foundation and guided trainer | Preserve and extend | +| Projects | Existing stages and attachments | Add visual role/stage relations and stronger access capability | +| Orders | Provider-aware operations | Enforce typed fixed-price policy | +| Reviews | Admin moderation exists | Gate public display/submission per piece | +| Notifications | Durable queue exists | Preserve provider-degradation truth | + +## Screenshot Defects + +1. Portfolio filters use large pill containers, circular count badges, fragile CSS `` icons, excessive wrap height, and weak count hierarchy. +2. Category editing exposes a giant native select with only Table, Bench, Stepstool, Cabinet, and Object choices. It lacks a visual gallery, custom SVG, public preview, order, and visibility. +3. Home service cards are inert articles and expose a private dashboard as a public service. The next section promotes Process rather than commissioned builds. +4. Footer contact lines use literal dot separators inside layout rules that can split each token into its own row. GitHub is duplicated and most footer structure is hard-coded. + +## Data Findings + +- Baseline local production-like backup contained 26 pieces, 426 indexed media rows, 10 projects, 7 pages, 2 users, no orders, and no reviews. +- Legacy piece JSON contained 196 media references. Three paths had no indexed media row; those were reported and left unmapped. +- Nine pieces used negative price sentinels and one used zero. They were not valid public money values. +- Piece/media assignment was duplicated and frequently inconsistent between piece JSON and media rows. +- Startup reapplied seed media relationships and forced `reviewed=1`; this could reverse an administrator's later unassignment or review decision. +- Related writes had no transaction wrapper, referential integrity was disabled, and destructive media operations lacked a durable rename ledger. +- WAL is part of the acknowledged database state. Backups must use SQLite online backup or include WAL/SHM consistently; copying only the main file is not sufficient while live. + +## Data Slice Implemented + +Commit `d35eb35` introduces: + +- `schema_migrations` with identity checks and per-migration reports; +- `piece_media_links` with role, stage, date, caption, alt override, order, public state, and compatibility provenance; +- typed price, inquiry, and review policy columns and helpers; +- `admin_edit_audit` and `media_rename_history`; +- non-destructive legacy media backfill; +- conservative pricing conversion: positive values become fixed, available inventory sentinels become contact-for-price, all other sentinels become not-listed; +- fixed-price-only cart enforcement at server actions and local reservation API; +- transactional reference rewrites with physical rename rollback and staged deletion; +- sidecar/temp filtering expansion; +- startup seed assignments limited to a genuinely new database; +- disposable migration, rollback, policy, and rename tests. + +Production-like disposable migration result: 26 pieces retained, 193 normalized valid links, three stale links reported, zero destructive legacy-column removal, schema version 3, and `PRAGMA quick_check=ok` after two application starts. + +## Accessibility Baseline + +- Present: semantic landmarks, labels on most controls, lightbox Escape/close/zoom, reduced-motion rule, no observed 320/390 page overflow in read-only live checks. +- Missing or incomplete: skip link/main target, consistent `:focus-visible`, active primary navigation state, modal focus trap/inert background, keyboard panning, structural-editor keyboard alternatives, contextual error association, and 200 percent reflow evidence. +- Header focus listener was attached globally and could reveal the sticky header when focus moved anywhere on the page. + +## Performance Baseline + +- Portfolio renders every published piece and raw originals, producing very long mobile pages and oversized downloads. +- Homepage can render too many featured cards. +- Media Desk pages the main library but builds its verification queue from the full image library. +- Studio Pages/Pieces/Process mount every editor at once. +- Existing pseudo-3D is small but not functionally sufficient; true Three.js must be dynamically isolated from unrelated routes. +- Four overlapping CSS files increase override cost and visual regression risk. + +## Documentation Discrepancies + +- Documentation claimed visual media pickers were in Page, Piece, and Process editors; source still used raw path fields and `MediaPicker` had no consumer. +- PLANS described the guided media trainer as both validation-pending and deployed/live-verified in different sections. +- Documentation described visual/semantic search capabilities more strongly than provider configuration and current public UI support justified. +- Persistence documentation was directionally correct about the mount, but did not explain startup seed reassignment or intentional-empty form semantics. +- The visualizer was labeled 3D despite rendering CSS geometry and SVG. + +## Requirement-to-File Map + +| Requirement | Primary implementation areas | +| --- | --- | +| Design system and density | `site/app/globals.css`, `site/app/refinements.css`, `site/app/ui-repair.css`, shared shell components | +| Inline editing | `site/components/inline-edit-*`, `site/app/api/studio/inline-edit/route.ts`, typed registry and audit helpers | +| Visual media selection | `site/components/media-picker.tsx`, Studio editors, media load actions | +| Normalized piece media/process | `site/lib/db.ts`, `site/lib/catalog.ts`, piece page and Studio Piece editor | +| Category icons | `site/lib/categories.ts`, shared SVG component, Portfolio and Studio category editor | +| Media operations | `site/components/studio-media-workspace.tsx`, `site/lib/actions.ts`, `site/lib/media.ts`, sidecar | +| Pricing/inquiry/reviews | `site/lib/piece-model.ts`, Shop/cart APIs, piece page, Studio Piece editor | +| Home/commissions/footer | home route, commissions flow, site chrome, structured settings | +| True 3D | dynamically imported R3F client surface, generator registry, SVG fallback | +| Deployment/data safety | Docker/Compose, migration tests, backup and rollback documentation | + +## Implementation and Validation Sequence + +1. Establish migration-safe normalized data and typed public policy. Completed in `d35eb35`. +2. Consolidate design tokens and public shell; repair footer, services, home commission positioning, and Portfolio density. +3. Implement extensible shared SVG category icons and visual category administration. +4. Connect a paged, focus-safe media library dialog to Page, Piece, Process, profile, and project editors; remove raw paths from normal workflows. +5. Convert long Studio panels to searchable master-detail editors and in-place action forms. +6. Render normalized process/build records and policy-gated pricing, inquiry, and reviews. +7. Extend Media Desk batch operations, rename previews, roles/stages, and provider-safe derivatives. +8. Replace pseudo-3D with dynamically loaded R3F generators and retain an honest scale-drawing fallback. +9. Replace inline-edit conditionals with a typed registry and atomic audited patch application. +10. Run static, disposable-database, browser, accessibility, performance, Docker, backup, candidate, rollback, and live deployment gates. + +## Validation Matrix + +| Gate | Baseline/data-slice status | +| --- | --- | +| TypeScript | Passed after data slice | +| ESLint | Passed after data slice | +| Focused Node tests | 11 passed; SQLite API/module warnings documented | +| Production build | Passed after data slice | +| Disposable migration twice | Passed; schema version remained 3 | +| SQLite quick check | Passed before/after | +| Destructive production mutation | Not performed | +| Public browser matrix | Pending redesigned surfaces | +| Admin browser matrix | Pending redesigned surfaces | +| Accessibility matrix | Pending redesigned surfaces | +| Performance comparison | Pending redesigned surfaces | +| Docker candidate/deploy | Pending all implementation and safety gates | From 959a2ca52d8195bf3b9781056796c6a23fec6be6 Mon Sep 17 00:00:00 2001 From: Cooper Beaman <55124795+lowestprime@users.noreply.github.com> Date: Sat, 11 Jul 2026 06:17:29 -0700 Subject: [PATCH 03/43] feat(categories): add visual icon management --- site/app/globals.css | 224 ++++++++++++++++++--- site/app/portfolio/page.tsx | 7 +- site/app/studio/page.tsx | 25 +-- site/components/category-icon.tsx | 40 ++++ site/components/site-chrome.tsx | 16 +- site/components/studio-category-editor.tsx | 130 ++++++++++++ site/lib/actions.ts | 112 ++++++++--- site/lib/catalog.ts | 2 +- site/lib/categories.ts | 87 ++++++-- site/lib/category-icons.test.mts | 44 ++++ site/lib/category-icons.ts | 159 +++++++++++++++ site/package.json | 2 +- 12 files changed, 733 insertions(+), 115 deletions(-) create mode 100644 site/components/category-icon.tsx create mode 100644 site/components/studio-category-editor.tsx create mode 100644 site/lib/category-icons.test.mts create mode 100644 site/lib/category-icons.ts diff --git a/site/app/globals.css b/site/app/globals.css index ad11d35..a4d4004 100644 --- a/site/app/globals.css +++ b/site/app/globals.css @@ -1853,35 +1853,38 @@ img.cleanup-subject-isolate { height: 0.42rem; } -.portfolio-filter-row { - display: flex; - flex-wrap: wrap; - gap: 0.85rem; - margin: 2rem 0; -} - -.portfolio-filter-pill { - display: inline-flex; - align-items: center; - gap: 0.8rem; - min-height: 3rem; - padding: 0.75rem 1rem; - border: 1px solid var(--line); - border-radius: var(--radius-pill); - background: color-mix(in srgb, var(--bg-elevated) 84%, transparent); - color: var(--muted); -} +.portfolio-filter-row { + display: flex; + flex-wrap: wrap; + gap: 0.42rem; + margin: 1.15rem 0 1.4rem; +} + +.portfolio-filter-pill { + display: inline-flex; + align-items: center; + gap: 0.52rem; + min-height: 2.75rem; + padding: 0.42rem 0.62rem; + border: 1px solid var(--line); + border-radius: 0.72rem; + background: color-mix(in srgb, var(--bg-elevated) 84%, transparent); + color: var(--muted); + font-size: 0.86rem; + line-height: 1; + text-decoration: none; +} .portfolio-filter-pill strong { display: inline-grid; place-items: center; - min-width: 1.6rem; - height: 1.6rem; - padding-inline: 0.35rem; + min-width: 1.35rem; + height: 1.35rem; + padding-inline: 0.28rem; border-radius: var(--radius-pill); background: color-mix(in srgb, var(--accent) 12%, transparent); color: var(--accent); - font-size: 0.78rem; + font-size: 0.7rem; line-height: 1; } @@ -1891,18 +1894,179 @@ img.cleanup-subject-isolate { background: color-mix(in srgb, var(--accent) 10%, var(--bg-elevated)); } -.portfolio-filter-mark { +.portfolio-filter-mark { display: inline-grid; place-items: center; - width: 1.9rem; - height: 1.9rem; - border-radius: 50%; + width: 1.55rem; + height: 1.55rem; + border-radius: 0.42rem; background: color-mix(in srgb, var(--accent) 16%, transparent); color: var(--accent); - font-size: 0.82rem; -} - -.shop-detail-list { +} + +.category-icon-svg { + display: block; + width: 1.12rem; + height: 1.12rem; + flex: 0 0 auto; + overflow: visible; +} + +.category-icon-custom > svg { + display: block; + width: 100%; + height: 100%; +} + +@media (max-width: 640px) { + .portfolio-filter-row { + flex-wrap: nowrap; + margin-inline: calc(var(--page-gutter, 1rem) * -1); + padding: 0 var(--page-gutter, 1rem) 0.4rem; + overflow-x: auto; + overscroll-behavior-inline: contain; + scrollbar-width: thin; + scroll-snap-type: inline proximity; + } + + .portfolio-filter-pill { + flex: 0 0 auto; + scroll-snap-align: start; + } +} + +.category-editor-grid { + grid-template-columns: repeat(auto-fit, minmax(min(100%, 30rem), 1fr)); + align-items: start; +} + +.category-editor { + display: grid; + gap: 0.85rem; + padding: 1rem; +} + +.category-editor-head { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.category-editor-head h3, +.category-editor-head p { + margin: 0; +} + +.category-editor-preview { + display: grid; + place-items: center; + width: 2.75rem; + height: 2.75rem; + border: 1px solid color-mix(in srgb, var(--accent) 34%, var(--line)); + border-radius: 0.72rem; + background: color-mix(in srgb, var(--accent) 10%, var(--bg-elevated)); + color: var(--accent); +} + +.category-editor-preview .category-icon-svg { + width: 1.65rem; + height: 1.65rem; +} + +.category-icon-fieldset { + min-width: 0; + margin: 0; + padding: 0; + border: 0; +} + +.category-icon-fieldset legend { + margin-bottom: 0.45rem; + font-size: 0.8rem; + color: var(--muted); +} + +.category-icon-gallery { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(5.25rem, 1fr)); + gap: 0.38rem; +} + +.category-icon-choice { + position: relative; + display: grid; + grid-template-columns: 1.2rem 1fr; + align-items: center; + gap: 0.4rem; + min-height: 2.75rem; + padding: 0.38rem 0.48rem; + border: 1px solid var(--line); + border-radius: 0.65rem; + background: var(--bg-elevated); + color: var(--muted); + cursor: pointer; +} + +.category-icon-choice:has(input:focus-visible) { + outline: 2px solid var(--focus, var(--accent)); + outline-offset: 2px; +} + +.category-icon-choice.is-selected { + border-color: color-mix(in srgb, var(--accent) 52%, var(--line)); + background: color-mix(in srgb, var(--accent) 10%, var(--bg-elevated)); + color: var(--text); +} + +.category-icon-choice input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.category-icon-choice span { + font-size: 0.72rem; + line-height: 1.15; +} + +.category-custom-icon, +.category-delete-panel { + padding: 0.65rem 0.72rem; + border: 1px solid var(--line); + border-radius: 0.72rem; +} + +.category-custom-icon > summary, +.category-delete-panel > summary { + cursor: pointer; + font-size: 0.82rem; +} + +.category-custom-icon-controls, +.category-editor-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.55rem; + margin-top: 0.65rem; +} + +.category-icon-upload input { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; +} + +.form-status.success { + color: var(--success, #5f8b68); +} + +.form-status.error { + color: var(--danger, #b66a5d); +} + +.shop-detail-list { display: grid; gap: 0.55rem; margin: 0; diff --git a/site/app/portfolio/page.tsx b/site/app/portfolio/page.tsx index 7da969b..4e1e626 100644 --- a/site/app/portfolio/page.tsx +++ b/site/app/portfolio/page.tsx @@ -1,7 +1,8 @@ import type { Metadata } from "next"; import Link from "next/link"; import { connection } from "next/server"; -import { CategoryIcon, PageIntro, PageSection, PieceCard, Shell } from "@/components/site-chrome"; +import { CategoryIcon } from "@/components/category-icon"; +import { PageIntro, PageSection, PieceCard, Shell } from "@/components/site-chrome"; import { inlineEditAttrs } from "@/components/inline-editable"; import { getPiecePortfolioCategory, getPortfolioCategories } from "@/lib/catalog"; import { getPage, getSiteSettings, listPieces } from "@/lib/db"; @@ -17,7 +18,7 @@ export default async function PortfolioPage({ searchParams }: { searchParams: Pr const { category } = await searchParams; const page = getPage("portfolio"); const categories = getPortfolioCategories(getSiteSettings().pieceCategories); - const portfolioCategories = [{ key: "all", label: "All pieces", icon: "all" as const, aliases: [] }, ...categories]; + const portfolioCategories = [{ key: "all", label: "All pieces", icon: "all" as const, iconName: "all" as const, iconType: "builtin" as const, customIconSvg: null, aliases: [], sortOrder: -1, visible: true }, ...categories]; const selectedCategory = portfolioCategories.some((item) => item.key === category) ? String(category) : "all"; const allPieces = listPieces(); const pieces = allPieces.filter((piece) => selectedCategory === "all" || getPiecePortfolioCategory(piece, categories) === selectedCategory); @@ -52,7 +53,7 @@ export default async function PortfolioPage({ searchParams }: { searchParams: Pr href={href} key={item.key} > - + {item.label} {counts.get(item.key) ?? 0} diff --git a/site/app/studio/page.tsx b/site/app/studio/page.tsx index d5de965..68ab648 100644 --- a/site/app/studio/page.tsx +++ b/site/app/studio/page.tsx @@ -62,6 +62,7 @@ import { getAiServiceStatus } from "@/lib/ai-services"; import { PageIntro, PageSection, Shell } from "@/components/site-chrome"; import { StudioScrollRestore } from "@/components/studio-form"; import { StudioMediaWorkspace } from "@/components/studio-media-workspace"; +import { StudioCategoryEditor } from "@/components/studio-category-editor"; import { normalizePieceCategories, type PieceCategoryDefinition } from "@/lib/categories"; const STUDIO_MEDIA_PAGE_SIZE = 48; @@ -156,28 +157,6 @@ function PieceEditor({ piece, categories, highlight = false }: { piece: Omit -

Portfolio category

{category.label}

{category.icon}
-
- -
- - - -
- {!isNew ? ( -
- - - -
- ) : null} - - ); -} - function PostEditor({ post, highlight = false }: { post: Omit; highlight?: boolean }) { return (
@@ -487,7 +466,7 @@ export default async function StudioPage({ {currentPanel === "pages" ?

Pages

Public pages

Titles, intros, body copy, and hero media. Saving here writes to {persistence.databasePath} and revalidates the live route.

{pages.map((page) => )}
: null} {currentPanel === "pieces" ?

Pieces

Portfolio and shop pieces

Categories, availability, media assignments, shop asking price, and metadata.

{pieces.map((piece) => )}
: null} - {currentPanel === "categories" ?

Categories

Portfolio filters

Manage the category labels, matching terms, and icon styles used by the public portfolio and piece editor.

{categories.map((category) => )}
: null} + {currentPanel === "categories" ?

Categories

Portfolio filters

Choose a furniture icon, import a safe custom SVG, and control order and visibility without editing code.

{categories.map((category) => )}
: null} {currentPanel === "custom" ?

Custom work

Contact workflow types

Material menus, estimator defaults, and active custom request categories.

{commissionTypes.map((item) => )}
: null} {currentPanel === "people" ?

People

Accounts and public profiles

Rename profiles, replace contact emails, and remove accounts directly from the dashboard.

{users.map((user) => )}
: null} {currentPanel === "process" ?

Process

Process notes and references

Markdown body, cover images, external links, and publication state.

{posts.map((post) => )}
: null} diff --git a/site/components/category-icon.tsx b/site/components/category-icon.tsx new file mode 100644 index 0000000..4ebb6c7 --- /dev/null +++ b/site/components/category-icon.tsx @@ -0,0 +1,40 @@ +import type { SVGProps } from "react"; +import { normalizeBuiltinCategoryIcon, type BuiltinCategoryIconName } from "@/lib/category-icons"; +import type { PieceCategoryDefinition } from "@/lib/categories"; + +type IconProps = SVGProps & { + name?: string | null; + category?: Pick | null; + label?: string; +}; + +function BuiltinGeometry({ name }: { name: BuiltinCategoryIconName }) { + if (name === "all") return <>; + if (name === "table") return <>; + if (name === "desk") return <>; + if (name === "side-table") return <>; + if (name === "bench") return <>; + if (name === "chair") return <>; + if (name === "stool") return <>; + if (name === "cabinet") return <>; + if (name === "shelf") return <>; + if (name === "door") return <>; + if (name === "bed") return <>; + if (name === "frame") return <>; + if (name === "board") return <>; + if (name === "easel") return <>; + if (name === "clock") return <>; + return <>; +} + +export function CategoryIcon({ name, category, label, className = "", ...props }: IconProps) { + if (category?.iconType === "custom" && category.customIconSvg) { + return ; + } + const iconName = normalizeBuiltinCategoryIcon(category?.iconName ?? name); + return ( + + + + ); +} diff --git a/site/components/site-chrome.tsx b/site/components/site-chrome.tsx index 6bd6d5b..02fdce9 100644 --- a/site/components/site-chrome.tsx +++ b/site/components/site-chrome.tsx @@ -4,10 +4,11 @@ import { cookies } from "next/headers"; import { ThemeToggle } from "@/components/theme-toggle"; import { HeaderSearch } from "@/components/header-search"; import { HeaderShell } from "@/components/header-shell"; +import { CategoryIcon as SharedCategoryIcon } from "@/components/category-icon"; import { EditableText, inlineEditAttrs, type InlineEditTarget } from "@/components/inline-editable"; import { avatarGradientStyle } from "@/lib/avatar"; import { getDisplayMediaPaths, hasVerifiedMedia } from "@/lib/catalog"; -import { pieceCategoryIcon, type PieceCategoryDefinition, type PieceCategoryIcon } from "@/lib/categories"; +import { findPieceCategory, pieceCategoryIcon, type PieceCategoryDefinition } from "@/lib/categories"; import { formatDate, formatLeadTime, resolveAssetUrl, toMediaUrl } from "@/lib/format"; import { getCurrentUser } from "@/lib/auth"; import { getBandwidthSnapshot, getMedia, getSiteSettings, listCartItems, listPages, type PieceRecord, type PostRecord, type ProjectRecord } from "@/lib/db"; @@ -41,15 +42,7 @@ function EditGlyph() { return ; } -export function CategoryIcon({ category, icon }: { category: string; icon?: PieceCategoryIcon | "all" }) { - const key = icon ?? pieceCategoryIcon(category); - if (key === "all") return ; - if (key === "tables") return ; - if (key === "benches") return ; - if (key === "stepstools") return ; - if (key === "cabinets") return ; - return ; -} +export { CategoryIcon } from "@/components/category-icon"; const RESERVED_NAV_SLUGS = new Set(["home", "portfolio", "shop", "journal", "process", "commissions", "requests", "studio", "about", "account", "search", "media", "contact"]); @@ -114,7 +107,8 @@ export function PieceCard({ piece, categories }: { piece: PieceRecord; categorie const firstImage = getDisplayMediaPaths(piece)[0]; const verified = hasVerifiedMedia(piece); const media = firstImage ? getMedia(firstImage) : null; - return
{firstImage ? {media?.altText :
Media under review
}
{piece.category}{verified ? "Verified photography" : "Photography in progress"}

{piece.title}

{piece.summary}

{piece.availabilityLabel}Updated {formatDate(piece.updatedAt)}
; + const category = findPieceCategory(piece.category, categories); + return
{firstImage ? {media?.altText :
Media under review
}
{category?.label ?? piece.category}{verified ? "Verified photography" : "Photography in progress"}

{piece.title}

{piece.summary}

{piece.availabilityLabel}Updated {formatDate(piece.updatedAt)}
; } export function PostCard({ post }: { post: PostRecord }) { return
{post.publishedAt ? formatDate(post.publishedAt) : "Draft"}{post.sourceUrl ? "Reference" : "Behind the scenes"}

{post.title}

{post.excerpt}

; diff --git a/site/components/studio-category-editor.tsx b/site/components/studio-category-editor.tsx new file mode 100644 index 0000000..dc68d52 --- /dev/null +++ b/site/components/studio-category-editor.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { useActionState, useState, type ChangeEvent } from "react"; +import { CategoryIcon } from "@/components/category-icon"; +import { BUILTIN_CATEGORY_ICONS, sanitizeCategoryIconSvg, type BuiltinCategoryIconName } from "@/lib/category-icons"; +import type { PieceCategoryDefinition } from "@/lib/categories"; + +export type CategoryActionState = { + status: "idle" | "success" | "error"; + message: string; + categoryKey?: string; +}; + +type CategoryAction = (previousState: CategoryActionState, formData: FormData) => Promise; + +const initialState: CategoryActionState = { status: "idle", message: "" }; + +export function StudioCategoryEditor({ + category, + categories, + deleteAction, + isNew = false, + saveAction +}: { + category: PieceCategoryDefinition; + categories: PieceCategoryDefinition[]; + deleteAction: CategoryAction; + isNew?: boolean; + saveAction: CategoryAction; +}) { + const [saveState, saveFormAction, saving] = useActionState(saveAction, initialState); + const [deleteState, deleteFormAction, deleting] = useActionState(deleteAction, initialState); + const [iconType, setIconType] = useState(category.iconType); + const [iconName, setIconName] = useState(category.iconName); + const [customIconSvg, setCustomIconSvg] = useState(category.customIconSvg ?? ""); + + let safeCustomIcon: string | null = null; + let customIconError = ""; + if (iconType === "custom" && customIconSvg.trim()) { + try { + safeCustomIcon = sanitizeCategoryIconSvg(customIconSvg); + } catch (error) { + customIconError = error instanceof Error ? error.message : "The custom icon could not be read."; + } + } + + const previewCategory = { + ...category, + icon: iconName, + iconName, + iconType: iconType === "custom" && safeCustomIcon ? "custom" as const : "builtin" as const, + customIconSvg: safeCustomIcon + }; + + async function importSvg(event: ChangeEvent) { + const file = event.currentTarget.files?.[0]; + if (!file) return; + if (file.size > 12_000) { + setCustomIconSvg(""); + return; + } + setCustomIconSvg(await file.text()); + setIconType("custom"); + } + + return ( +
+
+ +

{isNew ? "New portfolio group" : "Portfolio group"}

{category.label}

+
+ +
+ + + + +
+ + +
+ +
+ Icon +
+ {BUILTIN_CATEGORY_ICONS.filter((icon) => icon.name !== "all").map((icon) => ( + + ))} +
+
+ +
+ Use a custom SVG icon +
+ + +
+