French in, English out, with the character counter tied to the same constant the renderer uses — so the warning and the space actually available on the panel cannot drift apart. Translation is a second call, not part of the save. A slow or broken service must never cost the shop its message: the row is stored first and marked pending, the translation follows, and a failure shows as a badge with a retry rather than as a lost notice. The status transitions are the subtle part and are pinned by tests. Editing the French clears the English, including a translation someone had corrected by hand — a translation of text that has changed is worse than no translation. Editing only the dates or the priority leaves it alone. A hand-written translation is never overwritten while its French stands, and emptying it returns the row to pending. "On the screen" is decided by the same selector the renderer uses, so the badge cannot disagree with the panel about which message is live. The preview is served by the device's own pipeline — payload, SVG, threshold — so what the admin sees is the shop window down to the last thresholded pixel. A preview drawn any other way would eventually disagree with reality, quietly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
194 lines
5.5 KiB
TypeScript
194 lines
5.5 KiB
TypeScript
'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<T = undefined> =
|
||
| { 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<ActionResult<{ id: string }>> {
|
||
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<ActionResult<{ textEn: string }>> {
|
||
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<ActionResult> {
|
||
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<ActionResult> {
|
||
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');
|
||
}
|