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).
122 lines
4.9 KiB
TypeScript
122 lines
4.9 KiB
TypeScript
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 };
|
||
});
|