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).
This commit is contained in:
chahinebrini 2026-05-16 02:24:42 +02:00
parent e370842072
commit 34491ad220
7 changed files with 141 additions and 11 deletions

View File

@ -0,0 +1,28 @@
-- Migration: 20260516_domain_submission_type
--
-- Erweitert domain_submissions um das `type`-Feld — spiegelt den Type der
-- zugehörigen user_custom_domains-Row wider.
--
-- Erlaubte Werte:
-- 'web' — Web-Domain-Block (bisheriges Verhalten, Default)
-- 'mail_domain' — Sender-Domain-Block im Mail-Filter
--
-- Note: 'mail_display_name' ist v1.0 nicht submittable (Guard in submit.post.ts),
-- daher kein CHECK-Constraint dafür — erleichtert spätere v1.1-Erweiterung.
--
-- Backfill: bestehende Rows erhalten den Type der zugehörigen user_custom_domains-Row.
-- Rows ohne passende CustomDomain (CASCADE-gelöscht) behalten das Default 'web'.
ALTER TABLE rebreak.domain_submissions
ADD COLUMN IF NOT EXISTS type TEXT NOT NULL DEFAULT 'web';
-- Backfill: JOIN auf user_custom_domains via custom_domain_id
UPDATE rebreak.domain_submissions ds
SET type = ucd.type
FROM rebreak.user_custom_domains ucd
WHERE ds.custom_domain_id = ucd.id
AND ds.type = 'web'; -- nur noch nicht gesetzte Rows (idempotent bei Re-Run)
-- Composite-Index für gefilterte Admin-Listen (type + status)
CREATE INDEX IF NOT EXISTS domain_submissions_type_status_idx
ON rebreak.domain_submissions (type, status);

View File

@ -394,6 +394,8 @@ model DomainSubmission {
postId String? @map("post_id") @db.Uuid
// "pending" | "approved" | "rejected"
status String @default("pending")
// "web" | "mail_domain" — spiegelt den Type der zugehörigen UserCustomDomain-Row
type String @default("web")
yesVotes Int @default(0) @map("yes_votes")
noVotes Int @default(0) @map("no_votes")
reviewNote String? @map("review_note")
@ -405,6 +407,7 @@ model DomainSubmission {
votes DomainVote[]
@@index([status, createdAt])
@@index([type, status])
@@map("domain_submissions")
@@schema("rebreak")
}

View File

@ -14,6 +14,7 @@ export default defineEventHandler(async (event) => {
// 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(
@ -55,9 +56,17 @@ export default defineEventHandler(async (event) => {
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): Die Domain "${domain}" wurde zur ReBreak-Blockliste hinzugefügt 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): Die Domain "${domain}" wurde zur ReBreak-Blockliste hinzugefügt. Füge am Ende diesen Satz exakt ein: "${statsLine}" Warm, direkt.`;
? `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 () => {

View File

@ -54,11 +54,14 @@ export default defineEventHandler(async (event) => {
// Admin-Review erkennt type via customDomain.type-Feld.
let postId: string | null = null;
if (plan === "pro") {
const isDisplayName = existing.type === "mail_display_name";
const label = isDisplayName ? "Display-Name-Pattern" : "Domain";
const postContent = isDisplayName
? `Domain-Vorschlag (Display-Name-Pattern): **${existing.domain}**\n\nIch schlage vor, dieses Absender-Muster zur globalen ReBreak-Sperrliste hinzuzufügen. Stimme ab: Sollte **${existing.domain}** global gesperrt werden?`
: `Domain-Vorschlag: **${existing.domain}**\n\nIch schlage vor, diese Domain zur globalen ReBreak-Sperrliste hinzuzufügen. Stimme ab: Sollte **${existing.domain}** global gesperrt werden?`;
// Community-Vote-Post-Text variiert je nach Type
let postContent: string;
if (existing.type === "mail_domain") {
postContent = `Domain-Vorschlag (Mail-Absender): **${existing.domain}**\n\nIch schlage vor, diese Absender-Domain zur globalen ReBreak-Sperrliste hinzuzufügen. Casino-Affiliates nutzen oft Mailing-Listen mit harmlosen Namen. Stimme ab: Sollte **${existing.domain}** global gesperrt werden?`;
} else {
// type === "web" (mail_display_name ist durch Guard oben schon blockiert)
postContent = `Domain-Vorschlag: **${existing.domain}**\n\nIch schlage vor, diese Domain zur globalen ReBreak-Sperrliste hinzuzufügen. Stimme ab: Sollte **${existing.domain}** global gesperrt werden?`;
}
const post = await db.communityPost.create({
data: {
userId: user.id,

View File

@ -160,13 +160,13 @@ export async function submitDomainForReview(
const submissionStatus = plan === "legend" ? "in_review" : "pending";
const db = usePrisma();
return db.$transaction(async (tx) => {
// Mark custom domain as submitted
// Mark custom domain as submitted + type lesen für Submission
const domain = await tx.userCustomDomain.update({
where: { id: customDomainId, userId },
data: { status: "submitted", postId: postId ?? null },
select: { id: true, domain: true },
select: { id: true, domain: true, type: true },
});
// Create submission record
// Create submission record — type aus CustomDomain kopieren
const submission = await tx.domainSubmission.create({
data: {
userId,
@ -174,6 +174,7 @@ export async function submitDomainForReview(
customDomainId,
postId: postId ?? null,
status: submissionStatus,
type: domain.type,
},
});
return { domain, submission };
@ -258,6 +259,7 @@ export async function adminApproveSubmission(
select: {
customDomainId: true,
domain: true,
type: true,
status: true,
userId: true,
postId: true,
@ -313,7 +315,10 @@ export async function adminApproveSubmission(
preview: sub.domain,
}).catch(() => {});
return db.domainSubmission.findUnique({ where: { id: submissionId } });
return db.domainSubmission.findUnique({
where: { id: submissionId },
select: { id: true, domain: true, type: true, userId: true, postId: true },
});
}
export async function adminRejectSubmission(
@ -369,6 +374,8 @@ export const ADMIN_APPROVAL_SLA_MS = 24 * 60 * 60 * 1000;
export type PendingSubmissionRow = {
id: string;
domain: string;
/** "web" | "mail_domain" — spiegelt den Type der eingereichten CustomDomain */
type: string;
yesVotes: number;
noVotes: number;
status: string;
@ -406,6 +413,7 @@ export async function getPendingSubmissions(): Promise<PendingSubmissionRow[]> {
select: {
id: true,
domain: true,
type: true,
yesVotes: true,
noVotes: true,
status: true,

View File

@ -31,6 +31,7 @@ function makeRow(overrides: Partial<Record<string, unknown>> = {}) {
return {
id: "sub-id",
domain: "example.com",
type: "web",
yesVotes: 0,
noVotes: 0,
status: "in_review",
@ -119,4 +120,32 @@ describe("getPendingSubmissions — Plan-Priority + Deadline", () => {
const result = await getPendingSubmissions();
expect(result[0]!.planPriority).toBe(0);
});
it("returned type='web' für Web-Submission", async () => {
prismaMock.domainSubmission.findMany.mockResolvedValueOnce([
makeRow({ id: "web-sub", domain: "casino.de", type: "web" }),
]);
const result = await getPendingSubmissions();
expect(result[0]!.type).toBe("web");
});
it("returned type='mail_domain' für Mail-Submission", async () => {
prismaMock.domainSubmission.findMany.mockResolvedValueOnce([
makeRow({
id: "mail-sub",
domain: "mailing.casino-affiliate.com",
type: "mail_domain",
}),
]);
const result = await getPendingSubmissions();
expect(result[0]!.type).toBe("mail_domain");
});
it("type ist im Response-Objekt vorhanden (passthrough)", async () => {
prismaMock.domainSubmission.findMany.mockResolvedValueOnce([
makeRow({ type: "mail_domain" }),
]);
const result = await getPendingSubmissions();
expect(Object.prototype.hasOwnProperty.call(result[0], "type")).toBe(true);
});
});

View File

@ -247,6 +247,56 @@ describe("countActiveCustomDomainsSplit — Slot-Counting-Semantik (Dokumentatio
});
});
// ─── DomainSubmission type-Kopier-Semantik ────────────────────────────────────
/**
* Dokumentiert die Regel: submitDomainForReview kopiert type aus UserCustomDomain.
* Kein DB-Test reine Semantik-Verifikation.
*/
describe("DomainSubmission.type — Type-Kopier-Semantik beim Submit", () => {
it("type='web' aus CustomDomain → DomainSubmission.type='web'", () => {
// Spiegelt die Logik in submitDomainForReview: domain.type wird in Submission kopiert
const customDomainType = "web";
const submissionType = customDomainType; // direkte Zuweisung im DB-Layer
expect(submissionType).toBe("web");
});
it("type='mail_domain' aus CustomDomain → DomainSubmission.type='mail_domain'", () => {
const customDomainType = "mail_domain";
const submissionType = customDomainType;
expect(submissionType).toBe("mail_domain");
});
it("type='web' ist Default falls CustomDomain fehlt (Backfill-Semantik)", () => {
// Backfill-Migration: Rows ohne JOIN-Match behalten DEFAULT 'web'
const fallback = "web";
expect(fallback).toBe("web");
});
it("Community-Vote-Post-Text für mail_domain enthält Hinweis auf Mail-Absender", () => {
// Spiegelt die Logik in submit.post.ts — type-aware Post-Content
const type = "mail_domain";
const domain = "mailing.casino-affiliate.com";
const postContent =
type === "mail_domain"
? `Domain-Vorschlag (Mail-Absender): **${domain}**\n\nIch schlage vor, diese Absender-Domain zur globalen ReBreak-Sperrliste hinzuzufügen. Casino-Affiliates nutzen oft Mailing-Listen mit harmlosen Namen. Stimme ab: Sollte **${domain}** global gesperrt werden?`
: `Domain-Vorschlag: **${domain}**\n\nIch schlage vor, diese Domain zur globalen ReBreak-Sperrliste hinzuzufügen. Stimme ab: Sollte **${domain}** global gesperrt werden?`;
expect(postContent).toContain("Mail-Absender");
expect(postContent).toContain(domain);
});
it("Community-Vote-Post-Text für web enthält keinen Mail-Absender-Hinweis", () => {
const type = "web";
const domain = "casino.de";
const postContent =
type === "mail_domain"
? `Domain-Vorschlag (Mail-Absender): **${domain}**`
: `Domain-Vorschlag: **${domain}**\n\nIch schlage vor, diese Domain zur globalen ReBreak-Sperrliste hinzuzufügen.`;
expect(postContent).not.toContain("Mail-Absender");
expect(postContent).toContain(domain);
});
});
// ─── Submit-Guard mail_display_name ──────────────────────────────────────────
describe("Submit-Guard — DISPLAY_NAME_NOT_SUBMITTABLE", () => {