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:
parent
e370842072
commit
34491ad220
@ -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);
|
||||||
@ -394,6 +394,8 @@ model DomainSubmission {
|
|||||||
postId String? @map("post_id") @db.Uuid
|
postId String? @map("post_id") @db.Uuid
|
||||||
// "pending" | "approved" | "rejected"
|
// "pending" | "approved" | "rejected"
|
||||||
status String @default("pending")
|
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")
|
yesVotes Int @default(0) @map("yes_votes")
|
||||||
noVotes Int @default(0) @map("no_votes")
|
noVotes Int @default(0) @map("no_votes")
|
||||||
reviewNote String? @map("review_note")
|
reviewNote String? @map("review_note")
|
||||||
@ -405,6 +407,7 @@ model DomainSubmission {
|
|||||||
votes DomainVote[]
|
votes DomainVote[]
|
||||||
|
|
||||||
@@index([status, createdAt])
|
@@index([status, createdAt])
|
||||||
|
@@index([type, status])
|
||||||
@@map("domain_submissions")
|
@@map("domain_submissions")
|
||||||
@@schema("rebreak")
|
@@schema("rebreak")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
// Lyra-Post über die neu genehmigte Domain (fire & forget)
|
// Lyra-Post über die neu genehmigte Domain (fire & forget)
|
||||||
const domain = (result as any)?.domain ?? null;
|
const domain = (result as any)?.domain ?? null;
|
||||||
|
const domainType: string = (result as any)?.type ?? "web";
|
||||||
const submitterUserId = (result as any)?.userId ?? null;
|
const submitterUserId = (result as any)?.userId ?? null;
|
||||||
const lyraBotUserId = config.lyraBotUserId;
|
const lyraBotUserId = config.lyraBotUserId;
|
||||||
console.log(
|
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)` : ""}.`;
|
statsLine = `Damit schützen wir gemeinsam vor ${stats.toLocaleString("de-DE")} Domains${monthlyAdded > 0 ? ` (+${monthlyAdded} diesen Monat)` : ""}.`;
|
||||||
} catch {}
|
} 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
|
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): ${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): 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}. Füge am Ende diesen Satz exakt ein: "${statsLine}" Warm, direkt.`;
|
||||||
|
|
||||||
const groqApiKey = config.groqApiKey;
|
const groqApiKey = config.groqApiKey;
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|||||||
@ -54,11 +54,14 @@ export default defineEventHandler(async (event) => {
|
|||||||
// Admin-Review erkennt type via customDomain.type-Feld.
|
// Admin-Review erkennt type via customDomain.type-Feld.
|
||||||
let postId: string | null = null;
|
let postId: string | null = null;
|
||||||
if (plan === "pro") {
|
if (plan === "pro") {
|
||||||
const isDisplayName = existing.type === "mail_display_name";
|
// Community-Vote-Post-Text variiert je nach Type
|
||||||
const label = isDisplayName ? "Display-Name-Pattern" : "Domain";
|
let postContent: string;
|
||||||
const postContent = isDisplayName
|
if (existing.type === "mail_domain") {
|
||||||
? `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?`
|
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?`;
|
||||||
: `Domain-Vorschlag: **${existing.domain}**\n\nIch schlage vor, diese Domain zur globalen ReBreak-Sperrliste hinzuzufügen. 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({
|
const post = await db.communityPost.create({
|
||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
|||||||
@ -160,13 +160,13 @@ export async function submitDomainForReview(
|
|||||||
const submissionStatus = plan === "legend" ? "in_review" : "pending";
|
const submissionStatus = plan === "legend" ? "in_review" : "pending";
|
||||||
const db = usePrisma();
|
const db = usePrisma();
|
||||||
return db.$transaction(async (tx) => {
|
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({
|
const domain = await tx.userCustomDomain.update({
|
||||||
where: { id: customDomainId, userId },
|
where: { id: customDomainId, userId },
|
||||||
data: { status: "submitted", postId: postId ?? null },
|
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({
|
const submission = await tx.domainSubmission.create({
|
||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
@ -174,6 +174,7 @@ export async function submitDomainForReview(
|
|||||||
customDomainId,
|
customDomainId,
|
||||||
postId: postId ?? null,
|
postId: postId ?? null,
|
||||||
status: submissionStatus,
|
status: submissionStatus,
|
||||||
|
type: domain.type,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return { domain, submission };
|
return { domain, submission };
|
||||||
@ -258,6 +259,7 @@ export async function adminApproveSubmission(
|
|||||||
select: {
|
select: {
|
||||||
customDomainId: true,
|
customDomainId: true,
|
||||||
domain: true,
|
domain: true,
|
||||||
|
type: true,
|
||||||
status: true,
|
status: true,
|
||||||
userId: true,
|
userId: true,
|
||||||
postId: true,
|
postId: true,
|
||||||
@ -313,7 +315,10 @@ export async function adminApproveSubmission(
|
|||||||
preview: sub.domain,
|
preview: sub.domain,
|
||||||
}).catch(() => {});
|
}).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(
|
export async function adminRejectSubmission(
|
||||||
@ -369,6 +374,8 @@ export const ADMIN_APPROVAL_SLA_MS = 24 * 60 * 60 * 1000;
|
|||||||
export type PendingSubmissionRow = {
|
export type PendingSubmissionRow = {
|
||||||
id: string;
|
id: string;
|
||||||
domain: string;
|
domain: string;
|
||||||
|
/** "web" | "mail_domain" — spiegelt den Type der eingereichten CustomDomain */
|
||||||
|
type: string;
|
||||||
yesVotes: number;
|
yesVotes: number;
|
||||||
noVotes: number;
|
noVotes: number;
|
||||||
status: string;
|
status: string;
|
||||||
@ -406,6 +413,7 @@ export async function getPendingSubmissions(): Promise<PendingSubmissionRow[]> {
|
|||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
domain: true,
|
domain: true,
|
||||||
|
type: true,
|
||||||
yesVotes: true,
|
yesVotes: true,
|
||||||
noVotes: true,
|
noVotes: true,
|
||||||
status: true,
|
status: true,
|
||||||
|
|||||||
@ -31,6 +31,7 @@ function makeRow(overrides: Partial<Record<string, unknown>> = {}) {
|
|||||||
return {
|
return {
|
||||||
id: "sub-id",
|
id: "sub-id",
|
||||||
domain: "example.com",
|
domain: "example.com",
|
||||||
|
type: "web",
|
||||||
yesVotes: 0,
|
yesVotes: 0,
|
||||||
noVotes: 0,
|
noVotes: 0,
|
||||||
status: "in_review",
|
status: "in_review",
|
||||||
@ -119,4 +120,32 @@ describe("getPendingSubmissions — Plan-Priority + Deadline", () => {
|
|||||||
const result = await getPendingSubmissions();
|
const result = await getPendingSubmissions();
|
||||||
expect(result[0]!.planPriority).toBe(0);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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 ──────────────────────────────────────────
|
// ─── Submit-Guard mail_display_name ──────────────────────────────────────────
|
||||||
|
|
||||||
describe("Submit-Guard — DISPLAY_NAME_NOT_SUBMITTABLE", () => {
|
describe("Submit-Guard — DISPLAY_NAME_NOT_SUBMITTABLE", () => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user