From 34491ad220d89d6973472d42abb2e44d14fe3b83 Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Sat, 16 May 2026 02:24:42 +0200 Subject: [PATCH] feat(backend): denormalize domain_submissions.type for admin + lyra + notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../migration.sql | 28 +++++++++++ backend/prisma/schema.prisma | 3 ++ .../domain-submissions/[id]/approve.post.ts | 13 ++++- .../api/custom-domains/[id]/submit.post.ts | 13 +++-- backend/server/db/domains.ts | 16 ++++-- backend/tests/admin/domains.test.ts | 29 +++++++++++ .../tests/custom-domains/plan-limits.test.ts | 50 +++++++++++++++++++ 7 files changed, 141 insertions(+), 11 deletions(-) create mode 100644 backend/prisma/migrations/20260516_domain_submission_type/migration.sql diff --git a/backend/prisma/migrations/20260516_domain_submission_type/migration.sql b/backend/prisma/migrations/20260516_domain_submission_type/migration.sql new file mode 100644 index 0000000..0a9ab4d --- /dev/null +++ b/backend/prisma/migrations/20260516_domain_submission_type/migration.sql @@ -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); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index b8bb6d2..9b85558 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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") } diff --git a/backend/server/api/admin/domain-submissions/[id]/approve.post.ts b/backend/server/api/admin/domain-submissions/[id]/approve.post.ts index 7c138b6..bede243 100644 --- a/backend/server/api/admin/domain-submissions/[id]/approve.post.ts +++ b/backend/server/api/admin/domain-submissions/[id]/approve.post.ts @@ -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 () => { diff --git a/backend/server/api/custom-domains/[id]/submit.post.ts b/backend/server/api/custom-domains/[id]/submit.post.ts index e603189..6d6d886 100644 --- a/backend/server/api/custom-domains/[id]/submit.post.ts +++ b/backend/server/api/custom-domains/[id]/submit.post.ts @@ -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, diff --git a/backend/server/db/domains.ts b/backend/server/db/domains.ts index 4938da0..83c0bb7 100644 --- a/backend/server/db/domains.ts +++ b/backend/server/db/domains.ts @@ -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 { select: { id: true, domain: true, + type: true, yesVotes: true, noVotes: true, status: true, diff --git a/backend/tests/admin/domains.test.ts b/backend/tests/admin/domains.test.ts index ae88821..4d381f8 100644 --- a/backend/tests/admin/domains.test.ts +++ b/backend/tests/admin/domains.test.ts @@ -31,6 +31,7 @@ function makeRow(overrides: Partial> = {}) { 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); + }); }); diff --git a/backend/tests/custom-domains/plan-limits.test.ts b/backend/tests/custom-domains/plan-limits.test.ts index 090e805..6e65d69 100644 --- a/backend/tests/custom-domains/plan-limits.test.ts +++ b/backend/tests/custom-domains/plan-limits.test.ts @@ -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", () => {