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
|
||||
// "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")
|
||||
}
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user