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", () => {