doxa-maps-cdn/server/utils/site-settings.ts
corsacca 7dfad0ee07 replace deepl with llm translation via openrouter
- translateText/translateTexts call OpenRouter chat-completions; glossary
  terms are prompt-injected from config/glossaries.ts, generated from the
  translation repo's TSVs by scripts/build-glossaries.ts
- model resolution: per-language override in config/languages.ts →
  translation.model site setting (new admin field) → TRANSLATION_MODEL env
  → anthropic/claude-opus-5, at low reasoning effort
- blank fragments are held back from the model, which drops them and breaks
  the one-translation-per-fragment count the caller maps results back on
- OpenRouter failures map to a typed error with a human-readable message;
  an unrecoverable one (no credits, bad key, rejected model) stops the run
  instead of repeating the same call for every remaining locale
- machine translations record source 'auto-translate'; 'deepl' stays
  readable for snapshots written before the switch
2026-09-07 16:30:48 +01:00

129 lines
3.9 KiB
TypeScript

import { z } from 'zod'
import { sql } from 'kysely'
import { db } from './database'
// Single source of truth for valid keys, value shapes, defaults, and which
// keys are public-readable. Add a row here to introduce a new setting.
const settingDefs = {
'auth.public_registration_enabled': {
schema: z.boolean(),
default: true,
public: true,
},
// OpenRouter model id used for CMS auto-translation. Empty means "no
// override" — resolution then falls through to TRANSLATION_MODEL and the
// code default in server/utils/openrouter.ts. Free-text on purpose: the
// value is passed straight to the OpenRouter API, so a newly released model
// can be adopted by typing its id.
'translation.model': {
schema: z.string(),
default: '',
public: false,
},
} as const
export type SettingKey = keyof typeof settingDefs
export type SettingValue<K extends SettingKey> = z.infer<typeof settingDefs[K]['schema']>
export const SETTING_KEYS = Object.keys(settingDefs) as SettingKey[]
export const PUBLIC_SETTING_KEYS = SETTING_KEYS.filter(k => settingDefs[k].public)
export function isSettingKey(value: string): value is SettingKey {
return value in settingDefs
}
const CACHE_TTL_MS = 30_000
interface CacheEntry {
value: unknown
expiresAt: number
}
const cache = new Map<SettingKey, CacheEntry>()
function readCache(key: SettingKey): unknown | undefined {
const entry = cache.get(key)
if (!entry) return undefined
if (entry.expiresAt <= Date.now()) {
cache.delete(key)
return undefined
}
return entry.value
}
function writeCache(key: SettingKey, value: unknown) {
cache.set(key, { value, expiresAt: Date.now() + CACHE_TTL_MS })
}
function parseOrDefault<K extends SettingKey>(key: K, raw: unknown): SettingValue<K> {
const def = settingDefs[key]
const result = def.schema.safeParse(raw)
if (result.success) return result.data as SettingValue<K>
return def.default as SettingValue<K>
}
export async function getSetting<K extends SettingKey>(key: K): Promise<SettingValue<K>> {
const cached = readCache(key)
if (cached !== undefined) return parseOrDefault(key, cached)
const row = await db
.selectFrom('site_settings')
.select('value')
.where('key', '=', key)
.executeTakeFirst()
const raw = row?.value
writeCache(key, raw)
return parseOrDefault(key, raw)
}
export async function getSettings<K extends SettingKey>(
keys: readonly K[]
): Promise<{ [P in K]: SettingValue<P> }> {
const result = {} as { [P in K]: SettingValue<P> }
// Sequential is fine — keys are few and most are cache hits.
for (const key of keys) {
result[key] = await getSetting(key)
}
return result
}
export async function setSetting<K extends SettingKey>(
key: K,
value: unknown
): Promise<SettingValue<K> | undefined> {
const def = settingDefs[key]
const parsed = def.schema.safeParse(value)
if (!parsed.success) {
throw createError({
statusCode: 400,
statusMessage: `Invalid value for ${key}: ${parsed.error.issues.map(i => i.message).join(', ')}`
})
}
const existing = await db
.selectFrom('site_settings')
.select('value')
.where('key', '=', key)
.executeTakeFirst()
const previous = existing ? parseOrDefault(key, existing.value) : undefined
// Inline the JSON literal in SQL rather than passing it as a bound parameter:
// postgres-js auto-stringifies JS values for unknown-typed parameters, which
// would double-encode a pre-serialized primitive (e.g. JS string "false"
// -> JSONB string "false" instead of JSONB boolean false).
const json = JSON.stringify(parsed.data)
const escaped = json.replace(/'/g, "''")
const valueLit = sql.raw(`'${escaped}'::jsonb`)
const now = new Date().toISOString()
await sql`
INSERT INTO site_settings (key, value, updated)
VALUES (${key}, ${valueLit}, ${now}::timestamptz)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated = EXCLUDED.updated
`.execute(db)
cache.delete(key)
return previous
}