chahinebrini 34491ad220 feat(backend): denormalize domain_submissions.type for admin + lyra + notifications
User asked for the admin review tooling — and the lyra-bot community
post / notification text that goes out with each submission — to know
whether a submission is a website-domain or a mail-sender-domain. Until
now the type lived only on user_custom_domains and the submission
inherited it implicitly via the foreign key. Reading it back for the
admin list or the lyra prompt meant joining the source row every time.

- migration 20260516_domain_submission_type adds a type column to
  rebreak.domain_submissions with a default of 'web' and backfills
  every existing row from its linked user_custom_domains.type. The
  backfill is idempotent (UPDATE … FROM with the type comparison).
- Composite index (type, status) so the admin pending-list can scope
  by category without scanning the whole table.
- submitDomainForReview now copies the source row's type into the new
  submission. The submit endpoint picks it up to vary the auto-generated
  community-vote post copy: a website framing for type='web' and an
  "Mail-Absender"-framing for type='mail_domain'. The user's nickname
  is the only PII referenced.
- adminApproveSubmission returns the type alongside the domain so the
  approve endpoint's Lyra-bot Groq prompt can swap its subject/action
  labels per category. Reject path unchanged — the notification just
  carries the bare domain string, no type framing needed.
- BlocklistDomain stays type-agnostic on purpose. The mail-daemon's
  getBlocklistedDomainsSet is a flat string-set match against sender
  domain or URL host, and works for both categories without splitting.
  Adding a type there would be redundant work in v1.0 — revisit only
  if we ever need a UI to surface what category each global entry
  came from.

38/38 backend tests pass (8 admin/domains, 30 plan-limits including
5 new for the type-copy semantics and community-post text variants).
2026-05-16 02:24:42 +02:00

122 lines
4.9 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;
const mentionRef = hasMention
? `@${mentionName}`
: "einem Community-Mitglied";
// Stats für Lyra-Text holen
let statsLine = "";
try {
const stats = await db.blocklistDomain.count({
where: { isActive: true },
});
const startOfMonth = new Date();
startOfMonth.setDate(1);
startOfMonth.setHours(0, 0, 0, 0);
const monthlyAdded = await db.domainSubmission.count({
where: { status: "approved", reviewedAt: { gte: startOfMonth } },
});
statsLine = `Damit schützen wir gemeinsam vor ${stats.toLocaleString("de-DE")} Domains${monthlyAdded > 0 ? ` (+${monthlyAdded} diesen Monat)` : ""}.`;
} catch {}
// Prompt-Inhalt variiert je nach Type (web vs mail_domain)
const isMailDomain = domainType === "mail_domain";
const subjectLabel = isMailDomain
? `der Mail-Absender "${domain}"`
: `die Domain "${domain}"`;
const actionLabel = isMailDomain
? `zur ReBreak-Blockliste hinzugefügt casino-affiliates nutzen oft unauffällige Absender-Domains`
: `zur ReBreak-Blockliste hinzugefügt`;
const groqUserPrompt = hasMention
? `Schreibe einen kurzen Community-Post (max. 2 Sätze, auf Deutsch): ${subjectLabel} wurde ${actionLabel} möglich gemacht durch ${mentionRef}. Erwähne ${mentionRef} genau einmal. Füge am Ende diesen Satz exakt ein: "${statsLine}" Warm, direkt, kein doppelter Dank.`
: `Schreibe einen kurzen Community-Post (max. 2 Sätze, auf Deutsch): ${subjectLabel} wurde ${actionLabel}. Füge am Ende diesen Satz exakt ein: "${statsLine}" Warm, direkt.`;
const groqApiKey = config.groqApiKey;
(async () => {
try {
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: 150,
messages: [
{
role: "system",
content: `Du bist Lyra Recovery-Coach der ReBreak-Community. Tonalität: warm, persönlich, direkt. Schreibe NUR den Post-Text, kein Prefix, keine Anführungszeichen.`,
},
{
role: "user",
content: groqUserPrompt,
},
],
},
});
const content = response.choices?.[0]?.message?.content?.trim();
if (content) {
const faviconUrl = `https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=64`;
await db.communityPost.create({
data: {
userId: lyraBotUserId,
category: "domain_approved",
content,
imageUrl: faviconUrl,
isAnonymous: false,
isModerated: false,
},
});
console.log(
`[approve] Lyra-Post erstellt für domain=${domain}, submitter=${mentionName ?? "anonym"}`,
);
}
} catch (err) {
console.error(`[approve] Lyra-Post fehlgeschlagen:`, err);
}
})();
}
return { ok: true };
});