'use server'; import { revalidatePath } from 'next/cache'; import { diffOf, recordAudit } from '@/lib/audit'; import { requireAdmin } from '@/lib/auth/actions'; import { prisma } from '@/lib/db'; import { MESSAGE_MAX_CHARS } from '@/lib/screen/contract'; import { translateToEnglish } from '@/lib/translation/service'; export type ActionResult = | { ok: true; value: T } | { ok: false; error: string }; export type MessageInput = { id?: string; textFr: string; startsAt: string | null; endsAt: string | null; priority: number; isActive: boolean; }; export async function saveMessage(input: MessageInput): Promise> { const gate = await requireAdmin(); if (!gate.ok) { return { ok: false, error: gate.error }; } const textFr = input.textFr.trim(); if (textFr.length === 0) { return { ok: false, error: 'Le message ne peut pas être vide.' }; } if (textFr.length > MESSAGE_MAX_CHARS) { return { ok: false, error: `Le message dépasse ${MESSAGE_MAX_CHARS} caractères et ne tiendrait pas sur l’écran.`, }; } if (input.startsAt && input.endsAt && input.startsAt > input.endsAt) { return { ok: false, error: 'La fin de validité précède le début.' }; } const existing = input.id ? await prisma.message.findUnique({ where: { id: input.id } }) : null; // The French text changed, so whatever English sat next to it is stale — // including a translation someone had corrected by hand. const textChanged = existing ? existing.textFr !== textFr : true; const data = { textFr, startsAt: input.startsAt ? new Date(input.startsAt) : null, endsAt: input.endsAt ? new Date(input.endsAt) : null, priority: input.priority, isActive: input.isActive, ...(textChanged ? { textEn: null, translationStatus: 'PENDING' as const } : {}), }; const saved = existing ? await prisma.message.update({ where: { id: existing.id }, data }) : await prisma.message.create({ data: { ...data, createdBy: gate.value.email } }); await recordAudit({ userEmail: gate.value.email, action: existing ? 'message.update' : 'message.create', entity: 'Message', entityId: saved.id, diff: diffOf( existing ? { texte: existing.textFr, actif: existing.isActive, priorité: existing.priority, début: existing.startsAt, fin: existing.endsAt, } : null, { texte: textFr, actif: input.isActive, priorité: input.priority, début: data.startsAt, fin: data.endsAt, }, ), }); revalidateAll(); return { ok: true, value: { id: saved.id } }; } /** * Translates a saved message. * * Called separately from the save so a slow or broken translation service can * never cost the shop its message. Failure is recorded on the row and shown in * the UI with a retry, rather than raised. */ export async function translateMessage(id: string): Promise> { const gate = await requireAdmin(); if (!gate.ok) { return { ok: false, error: gate.error }; } const message = await prisma.message.findUnique({ where: { id } }); if (!message) { return { ok: false, error: 'Ce message a été supprimé.' }; } if (message.translationStatus === 'MANUAL') { return { ok: false, error: 'La traduction a été saisie à la main; elle n’est pas écrasée.' }; } const outcome = await translateToEnglish(message.textFr); if (!outcome.ok) { await prisma.message.update({ where: { id }, data: { translationStatus: 'ERROR' } }); revalidateAll(); return { ok: false, error: outcome.error }; } await prisma.message.update({ where: { id }, data: { textEn: outcome.text, translationStatus: 'DONE' }, }); revalidateAll(); return { ok: true, value: { textEn: outcome.text } }; } /** A hand-written translation is never overwritten while the French stands. */ export async function setManualTranslation(id: string, textEn: string): Promise { const gate = await requireAdmin(); if (!gate.ok) { return { ok: false, error: gate.error }; } const trimmed = textEn.trim(); const existing = await prisma.message.findUnique({ where: { id } }); if (!existing) { return { ok: false, error: 'Ce message a été supprimé.' }; } await prisma.message.update({ where: { id }, data: { textEn: trimmed || null, translationStatus: trimmed ? 'MANUAL' : 'PENDING', }, }); await recordAudit({ userEmail: gate.value.email, action: 'message.translate_manual', entity: 'Message', entityId: id, diff: diffOf({ anglais: existing.textEn }, { anglais: trimmed || null }), }); revalidateAll(); return { ok: true, value: undefined }; } export async function deleteMessage(id: string): Promise { const gate = await requireAdmin(); if (!gate.ok) { return { ok: false, error: gate.error }; } const existing = await prisma.message.findUnique({ where: { id } }); if (!existing) { return { ok: false, error: 'Ce message a déjà été supprimé.' }; } await prisma.message.delete({ where: { id } }); await recordAudit({ userEmail: gate.value.email, action: 'message.delete', entity: 'Message', entityId: id, diff: diffOf({ texte: existing.textFr }, null), }); revalidateAll(); return { ok: true, value: undefined }; } function revalidateAll(): void { revalidatePath('/admin'); revalidatePath('/admin/messages'); }