chahinebrini 44a3348845 feat(community): Domain-Approval-Lyra-Posts multi-locale (de/en/fr/ar)
Bug: User mit FR-locale sahen Lyra-Confirmation-Posts trotzdem auf Deutsch
(Banner/Tabs richtig FR). Root: approve.post.ts generierte den Text via
Groq mit hartcodiertem 'auf Deutsch'-Prompt, speicherte als plain content.

Server (approve.post.ts):
- 4 parallele Groq-Calls (Promise.allSettled) — de + en + fr + ar
- Per-Locale-PROMPT_CFG mit subject/action/statsLine/thanksSegment-Texten
- Locale-aware Number-Format (toLocaleString('de-DE'|'en-US'|'fr-FR'|'ar-EG'))
- Content als JSON {de:'...',en:'...',fr:'...',ar:'...'} gespeichert
- Mindestens DE muss gelingen, sonst kein Post (Sicherheit gegen halbe Posts)
- ~4x Groq-cost pro Post (sehr günstig bei Llama-3.3-70b, parallel-latency
  bleibt ähnlich)

Frontend (PostCard.tsx):
- resolveLocalizedJsonContent() — try-parsed JSON content
- Wenn JSON-Object mit Locale-Keys → pickt i18n.language, fällt auf DE → EN
- Sonst plain content (Legacy-Posts, Comments, User-Posts unverändert)
- Quick-Reject auf '{' first-char vermeidet JSON.parse-Overhead für 99.9%
  der Text-Posts

Legacy-Posts in DB bleiben DE-only (kein retroaktiver Multi-Locale-Rewrite).
Neue Posts ab Deploy haben alle 4 Sprachen.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 00:29:02 +02:00

195 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { adminApproveSubmission } from "../../../../db/domains";
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig();
const adminSecret = getHeader(event, "x-admin-secret");
if (adminSecret !== config.adminSecret) {
throw createError({ statusCode: 401, message: "Unauthorized" });
}
const id = getRouterParam(event, "id");
if (!id) throw createError({ statusCode: 400, message: "ID fehlt" });
const body = await readBody(event).catch(() => ({}));
const result = await adminApproveSubmission(id, body?.note);
// Lyra-Post über die neu genehmigte Domain (fire & forget)
const domain = (result as any)?.domain ?? null;
const domainType: string = (result as any)?.type ?? "web";
const submitterUserId = (result as any)?.userId ?? null;
const lyraBotUserId = config.lyraBotUserId;
console.log(
`[approve] domain=${domain}, lyraBotUserId=${lyraBotUserId}, hasGroq=${!!config.groqApiKey}`,
);
if (domain && lyraBotUserId && config.groqApiKey) {
// db + submitterName VOR der IIFE holen (usePrisma braucht Event-Kontext)
const db = usePrisma();
let rawName: string | null = null;
if (submitterUserId) {
try {
// Rebreak Prisma-Modell heißt 'profile' (nicht 'user')
const submitter = await db.profile.findUnique({
where: { id: submitterUserId },
select: { nickname: true, username: true },
});
rawName = submitter?.nickname || submitter?.username || null;
} catch {}
}
// Für @mention: Leerzeichen entfernen (Regex matcht nur einzelne Wörter)
const mentionName = rawName?.replace(/\s+/g, "") ?? null;
const hasMention = !!mentionName;
// Stats für Lyra-Text holen (Zahlen sind locale-agnostic, locale-Format
// bauen wir per-Sprache zusammen).
let totalDomains = 0;
let monthlyAdded = 0;
try {
totalDomains = await db.blocklistDomain.count({ where: { isActive: true } });
const startOfMonth = new Date();
startOfMonth.setDate(1);
startOfMonth.setHours(0, 0, 0, 0);
monthlyAdded = await db.domainSubmission.count({
where: { status: "approved", reviewedAt: { gte: startOfMonth } },
});
} catch {}
const isMailDomain = domainType === "mail_domain";
// Per-Locale-Prompt-Bausteine. 4 parallele Groq-Calls statt 1 — Latency
// bleibt ~gleich (parallel) + Output ist garantiert konsistent pro Sprache
// (vs. 1 Multi-Locale-Call der gerne JSON-Format-Fehler produziert).
type Lang = "de" | "en" | "fr" | "ar";
const LANGS: Lang[] = ["de", "en", "fr", "ar"];
const PROMPT_CFG: Record<Lang, {
promptLang: string;
anonRef: string;
subjectMail: string;
subjectWeb: string;
actionMail: string;
actionWeb: string;
statsLine: (total: number, monthly: number) => string;
thanksSegment: (mentionRef: string) => string;
}> = {
de: {
promptLang: "Deutsch",
anonRef: "einem Community-Mitglied",
subjectMail: `der Mail-Absender "${domain}"`,
subjectWeb: `die Domain "${domain}"`,
actionMail: `zur ReBreak-Blockliste hinzugefügt casino-affiliates nutzen oft unauffällige Absender-Domains`,
actionWeb: `zur ReBreak-Blockliste hinzugefügt`,
statsLine: (t, m) => `Damit schützen wir gemeinsam vor ${t.toLocaleString("de-DE")} Domains${m > 0 ? ` (+${m} diesen Monat)` : ""}.`,
thanksSegment: (m) => ` möglich gemacht durch ${m}. Erwähne ${m} genau einmal`,
},
en: {
promptLang: "English",
anonRef: "a community member",
subjectMail: `the mail sender "${domain}"`,
subjectWeb: `the domain "${domain}"`,
actionMail: `was added to the ReBreak blocklist — casino affiliates often use inconspicuous sender domains`,
actionWeb: `was added to the ReBreak blocklist`,
statsLine: (t, m) => `Together we now protect against ${t.toLocaleString("en-US")} domains${m > 0 ? ` (+${m} this month)` : ""}.`,
thanksSegment: (m) => `— made possible by ${m}. Mention ${m} exactly once`,
},
fr: {
promptLang: "français",
anonRef: "un membre de la communauté",
subjectMail: `l'expéditeur de mail « ${domain} »`,
subjectWeb: `le domaine « ${domain} »`,
actionMail: `a été ajouté à la liste de blocage ReBreak — les affiliés casino utilisent souvent des domaines d'expéditeur discrets`,
actionWeb: `a été ajouté à la liste de blocage ReBreak`,
statsLine: (t, m) => `Ensemble, nous protégeons maintenant contre ${t.toLocaleString("fr-FR")} domaines${m > 0 ? ` (+${m} ce mois-ci)` : ""}.`,
thanksSegment: (m) => `— rendu possible par ${m}. Mentionne ${m} exactement une fois`,
},
ar: {
promptLang: "العربية",
anonRef: "أحد أفراد المجتمع",
subjectMail: `المُرسِل البريدي «${domain}»`,
subjectWeb: `النطاق «${domain}»`,
actionMail: `تمت إضافته إلى قائمة الحظر في ReBreak — يستخدم المنتسبون للكازينوهات غالباً نطاقات مُرسِلين غير لافتة`,
actionWeb: `تمت إضافته إلى قائمة الحظر في ReBreak`,
statsLine: (t, m) => `معاً نحمي الآن من ${t.toLocaleString("ar-EG")} نطاق${m > 0 ? ` (+${m} هذا الشهر)` : ""}.`,
thanksSegment: (m) => `— بفضل ${m}. اذكر ${m} مرة واحدة فقط`,
},
};
const groqApiKey = config.groqApiKey;
(async () => {
try {
// 4 parallele Groq-Calls, eine pro Sprache. Promise.allSettled damit
// ein fehlgeschlagener Locale-Call die anderen 3 nicht killt — wir
// speichern dann nur die erfolgreichen.
const results = await Promise.allSettled(
LANGS.map(async (lang) => {
const cfg = PROMPT_CFG[lang];
const subject = isMailDomain ? cfg.subjectMail : cfg.subjectWeb;
const action = isMailDomain ? cfg.actionMail : cfg.actionWeb;
const stats = cfg.statsLine(totalDomains, monthlyAdded);
const mentionRef = hasMention ? `@${mentionName}` : cfg.anonRef;
const thanksPart = hasMention ? ` ${cfg.thanksSegment(mentionRef)}` : "";
const userPrompt = `Schreibe einen kurzen Community-Post (max. 2 Sätze, auf ${cfg.promptLang}): ${subject} ${action}${thanksPart}. Füge am Ende diesen Satz exakt ein: "${stats}" Warm, direkt, kein doppelter Dank.`;
const response = await $fetch<{
choices: { message: { content: string } }[];
}>("https://api.groq.com/openai/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${groqApiKey}`,
"Content-Type": "application/json",
},
body: {
model: "llama-3.3-70b-versatile",
max_tokens: 200,
messages: [
{
role: "system",
content: `Du bist Lyra Recovery-Coach der ReBreak-Community. Tonalität: warm, persönlich, direkt. Schreibe NUR den Post-Text in der angefragten Sprache, kein Prefix, keine Anführungszeichen.`,
},
{ role: "user", content: userPrompt },
],
},
});
const content = response.choices?.[0]?.message?.content?.trim();
if (!content) throw new Error("empty content");
return [lang, content] as const;
}),
);
const localized: Partial<Record<Lang, string>> = {};
for (const r of results) {
if (r.status === "fulfilled") {
const [lang, content] = r.value;
localized[lang] = content;
}
}
// Mindestens DE muss klappen — sonst kein Post (sicheres Fallback,
// statt einen halben deutsch-leeren Post zu schreiben).
if (!localized.de) {
console.error(`[approve] Lyra-Post abgebrochen: DE-Generation fehlgeschlagen für ${domain}`);
return;
}
const contentJson = JSON.stringify(localized);
const faviconUrl = `https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=64`;
await db.communityPost.create({
data: {
userId: lyraBotUserId,
category: "domain_approved",
// JSON-encoded mit Locale-Keys — PostCard.tsx try-parsed + picked
// current-locale; fällt auf DE zurück wenn locale fehlt.
content: contentJson,
imageUrl: faviconUrl,
isAnonymous: false,
isModerated: false,
},
});
console.log(
`[approve] Lyra-Post erstellt für domain=${domain}, submitter=${mentionName ?? "anonym"}, locales=${Object.keys(localized).join(",")}`,
);
} catch (err) {
console.error(`[approve] Lyra-Post fehlgeschlagen:`, err);
}
})();
}
return { ok: true };
});