{"version":3,"file":"bylines-D2FhWrOJ.mjs","names":[],"sources":["../src/api/handlers/bylines.ts"],"sourcesContent":["import type { Kysely } from \"kysely\";\n\nimport {\n\tBylineRepository,\n\ttype CreateBylineInput,\n\ttype UpdateBylineInput,\n} from \"../../database/repositories/byline.js\";\nimport { EmDashValidationError, type BylineSummary } from \"../../database/repositories/types.js\";\nimport type { Database } from \"../../database/types.js\";\nimport { getI18nConfig, resolveConfiguredLocale } from \"../../i18n/config.js\";\nimport type { ApiResult } from \"../types.js\";\n\n// `undefined → null` so a missing field in the create payload matches the\n// repo's stored `null` (BylineRepository normalises with `?? null` on write).\nconst norm = (v: string | null | undefined): string | null => v ?? null;\n\n/**\n * Whether the existing byline row's fixed columns match a fresh-create\n * payload after null/undefined normalisation. Used by the D1 create-retry\n * recovery branch.\n */\nfunction bylineFixedFieldsMatch(\n\texisting: BylineSummary,\n\tinput: CreateBylineInput,\n\teffectiveLocale: string,\n): boolean {\n\treturn (\n\t\texisting.displayName === input.displayName &&\n\t\tnorm(existing.bio) === norm(input.bio) &&\n\t\tnorm(existing.avatarMediaId) === norm(input.avatarMediaId) &&\n\t\tnorm(existing.websiteUrl) === norm(input.websiteUrl) &&\n\t\tnorm(existing.userId) === norm(input.userId) &&\n\t\texisting.isGuest === (input.isGuest ?? false) &&\n\t\texisting.locale === effectiveLocale\n\t);\n}\n\n/**\n * Whether every key in `existing` appears in `input` with the same value.\n * Allows `input` to contain additional keys (the partial-write recovery\n * case); rejects on a divergent value or a key the input omits.\n */\nfunction existingCustomFieldsAreSubsetOf(\n\texisting: Record<string, unknown>,\n\tinput: Record<string, unknown> | undefined,\n): boolean {\n\tif (!input) return Object.keys(existing).length === 0;\n\tfor (const [slug, value] of Object.entries(existing)) {\n\t\tif (!Object.hasOwn(input, slug)) return false;\n\t\tif (input[slug] !== value) return false;\n\t}\n\treturn true;\n}\n\n/**\n * Reject locales the site doesn't configure. Returns `null` when the locale\n * is fine (omitted, or matches `locales` in the i18n config, or i18n isn't\n * configured at all).\n */\nfunction rejectUnknownLocale(locale: string | undefined): ApiResult<never> | null {\n\tif (!locale) return null;\n\tconst config = getI18nConfig();\n\tif (!config) return null;\n\tif (config.locales.includes(locale)) return null;\n\treturn {\n\t\tsuccess: false,\n\t\terror: {\n\t\t\tcode: \"VALIDATION_ERROR\",\n\t\t\tmessage: `Locale \"${locale}\" is not configured for this site`,\n\t\t},\n\t};\n}\n\n/**\n * Business-logic helpers for the bylines admin API.\n *\n * Mirrors the shape of `packages/core/src/api/handlers/menus.ts`. Route files\n * stay thin: they parse input, call these handlers, and forward the result via\n * `unwrapResult`. The repository (`BylineRepository`) is strict per locale; the\n * handlers add validation and translation-flow guards on top.\n */\n\nexport interface BylineTranslationsResponse {\n\titems: BylineSummary[];\n}\n\n/**\n * List every translation of a byline (by row id). Returns NOT_FOUND when no\n * row with the given id exists.\n */\nexport async function handleBylineTranslations(\n\tdb: Kysely<Database>,\n\tid: string,\n): Promise<ApiResult<BylineTranslationsResponse>> {\n\ttry {\n\t\tconst repo = new BylineRepository(db);\n\t\tconst anchor = await repo.findById(id);\n\t\tif (!anchor) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"NOT_FOUND\", message: \"Byline not found\" },\n\t\t\t};\n\t\t}\n\t\tconst items = await repo.listTranslations(id);\n\t\treturn { success: true, data: { items } };\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: {\n\t\t\t\tcode: \"BYLINE_TRANSLATIONS_ERROR\",\n\t\t\t\tmessage: \"Failed to list byline translations\",\n\t\t\t},\n\t\t};\n\t}\n}\n\n/**\n * Create a new byline. When `translationOf` is supplied, the new row joins the\n * source byline's translation_group (a sibling in the same logical identity).\n *\n * Translating from a source row only makes sense when the caller names the\n * target locale, otherwise we'd silently clone into the configured default,\n * which is almost never what's intended (and will collide if the source is\n * already the default-locale row). Mirrors `handleMenuCreate`.\n */\nexport async function handleBylineCreate(\n\tdb: Kysely<Database>,\n\tinput: CreateBylineInput,\n): Promise<ApiResult<BylineSummary>> {\n\ttry {\n\t\tif (input.translationOf && !input.locale) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: {\n\t\t\t\t\tcode: \"VALIDATION_ERROR\",\n\t\t\t\t\tmessage: \"`locale` is required when `translationOf` is provided\",\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\tconst locale = input.locale ? resolveConfiguredLocale(input.locale) : undefined;\n\t\tconst localeErr = rejectUnknownLocale(locale);\n\t\tif (localeErr) return localeErr;\n\n\t\tconst repo = new BylineRepository(db);\n\n\t\t// Existence check up front so the repo's \"Source not found\" throw\n\t\t// becomes a clean NOT_FOUND on the API.\n\t\tlet sourceGroup: string | undefined;\n\t\tif (input.translationOf) {\n\t\t\tconst source = await repo.findById(input.translationOf);\n\t\t\tif (!source) {\n\t\t\t\treturn {\n\t\t\t\t\tsuccess: false,\n\t\t\t\t\terror: {\n\t\t\t\t\t\tcode: \"NOT_FOUND\",\n\t\t\t\t\t\tmessage: \"Source byline for translation not found\",\n\t\t\t\t\t},\n\t\t\t\t};\n\t\t\t}\n\t\t\tsourceGroup = source.translationGroup ?? source.id;\n\t\t}\n\n\t\tconst effectiveLocale = locale ?? getI18nConfig()?.defaultLocale ?? \"en\";\n\n\t\t// Translation-group guard: the row-per-locale model (PR #916)\n\t\t// allows exactly one row per (translation_group, locale). Reject\n\t\t// here so callers get a clean 409 instead of a UNIQUE constraint\n\t\t// failure from the partial index. The DB constraint is the safety\n\t\t// net; this is the friendly error.\n\t\tif (sourceGroup) {\n\t\t\tconst siblings = await repo.findByTranslationGroup(sourceGroup);\n\t\t\tif (siblings.some((b) => b.locale === effectiveLocale)) {\n\t\t\t\treturn {\n\t\t\t\t\tsuccess: false,\n\t\t\t\t\terror: {\n\t\t\t\t\t\tcode: \"CONFLICT\",\n\t\t\t\t\t\tmessage: `Translation already exists in locale \"${effectiveLocale}\" for this byline`,\n\t\t\t\t\t},\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\n\t\t// Duplicate guard: same (slug, locale) — matches the DB unique key\n\t\t// from migration 040.\n\t\tconst existing = await repo.findBySlug(input.slug, { locale: effectiveLocale });\n\t\tif (existing) {\n\t\t\t// D1 has no transactions, so a crash between the byline insert\n\t\t\t// and the per-field writes leaves a partial row that's\n\t\t\t// otherwise unrecoverable. Treat a same-identity retry that\n\t\t\t// provides customFields as completing the abandoned create.\n\t\t\t// Recovery requires fixed-column + translation-group +\n\t\t\t// subset-customFields match; anything else collapses to a\n\t\t\t// standard duplicate-slug conflict.\n\t\t\tconst expectedTranslationGroup = sourceGroup ?? existing.id;\n\t\t\tconst inputHasFields = !!input.customFields && Object.keys(input.customFields).length > 0;\n\t\t\tif (\n\t\t\t\tinputHasFields &&\n\t\t\t\tbylineFixedFieldsMatch(existing, input, effectiveLocale) &&\n\t\t\t\texisting.translationGroup === expectedTranslationGroup &&\n\t\t\t\texistingCustomFieldsAreSubsetOf(existing.customFields ?? {}, input.customFields)\n\t\t\t) {\n\t\t\t\tconst recovered = await repo.update(existing.id, {\n\t\t\t\t\tcustomFields: input.customFields,\n\t\t\t\t});\n\t\t\t\tif (recovered) return { success: true, data: recovered };\n\t\t\t}\n\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: {\n\t\t\t\t\tcode: \"CONFLICT\",\n\t\t\t\t\tmessage: `Byline \"${input.slug}\" already exists${\n\t\t\t\t\t\tinput.locale ? ` in locale \"${input.locale}\"` : \"\"\n\t\t\t\t\t}`,\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\tconst byline = await repo.create({ ...input, locale: effectiveLocale });\n\t\treturn { success: true, data: byline };\n\t} catch (error) {\n\t\t// Mirror handleBylineUpdate: surface customFields validation\n\t\t// errors as 400 rather than swallowing them as a generic 500.\n\t\tif (error instanceof EmDashValidationError) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"VALIDATION_ERROR\", message: error.message },\n\t\t\t};\n\t\t}\n\t\tconsole.error(\"[BYLINE_CREATE_ERROR]\", error);\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"BYLINE_CREATE_ERROR\", message: \"Failed to create byline\" },\n\t\t};\n\t}\n}\n\n/**\n * Update an existing byline. Forwards every field on `UpdateBylineInput`\n * to `BylineRepository.update`, including the Phase 3 `customFields`\n * map; per-field type validation lives in the repo, which throws\n * `EmDashValidationError` on unknown slugs, type mismatches, or\n * `select`-choice misses. This handler translates that into a clean\n * `VALIDATION_ERROR` (400 via `mapErrorStatus`).\n *\n * Returns `NOT_FOUND` when the byline id doesn't resolve. Generic\n * failures surface as `BYLINE_UPDATE_ERROR` (500) without leaking the\n * underlying message.\n */\nexport async function handleBylineUpdate(\n\tdb: Kysely<Database>,\n\tid: string,\n\tinput: UpdateBylineInput,\n): Promise<ApiResult<BylineSummary>> {\n\ttry {\n\t\tconst repo = new BylineRepository(db);\n\t\tconst byline = await repo.update(id, input);\n\t\tif (!byline) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"NOT_FOUND\", message: \"Byline not found\" },\n\t\t\t};\n\t\t}\n\t\treturn { success: true, data: byline };\n\t} catch (error) {\n\t\t// Unknown-key + type-mismatch + select-choice writes throw\n\t\t// EmDashValidationError (Phase 3, see BylineRepository.update).\n\t\t// Map to a clean 400 — the error message names the offending\n\t\t// slug/type, which is safe to surface to the admin client.\n\t\tif (error instanceof EmDashValidationError) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"VALIDATION_ERROR\", message: error.message },\n\t\t\t};\n\t\t}\n\t\tconsole.error(\"[BYLINE_UPDATE_ERROR]\", error);\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"BYLINE_UPDATE_ERROR\", message: \"Failed to update byline\" },\n\t\t};\n\t}\n}\n"],"mappings":"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"}