import { usePrisma } from "../utils/prisma"; import { createNotification } from "./notifications"; // ─── Types ─────────────────────────────────────────────────────────────────── /** * Typ eines Custom-Domain-Eintrags. * web — Web-Domain-Block (default, bisheriges Verhalten) * mail_domain — Sender-Domain-Block im Mail-Filter (Domain-Match) * mail_display_name — Sender-Display-Name-Pattern (Substring, case-insensitive) * * Alle Types teilen den gleichen Slot-Pool pro Plan. */ export type CustomDomainType = "web" | "mail_domain" | "mail_display_name"; export const CUSTOM_DOMAIN_TYPES: CustomDomainType[] = [ "web", "mail_domain", "mail_display_name", ]; // ─── Custom Domains ─────────────────────────────────────────────────────────── export async function getUserCustomDomains(userId: string) { const db = usePrisma(); const rows = await db.userCustomDomain.findMany({ where: { userId }, orderBy: { addedAt: "desc" }, select: { id: true, domain: true, status: true, type: true, postId: true, addedAt: true, submission: { select: { id: true, yesVotes: true, noVotes: true, status: true }, }, }, }); return rows; } /** * Counts domains that occupy a slot (active + submitted). * approved → slot freed (domain joined global list) * rejected → slot freed (user can re-submit or delete) * * @deprecated Use countActiveCustomDomainsSplit for per-type quota checks. */ export async function countActiveCustomDomains(userId: string) { const db = usePrisma(); return db.userCustomDomain.count({ where: { userId, status: { notIn: ["approved", "rejected"] } }, }); } /** * Returns per-bucket slot usage: * web = type === 'web' * mail = type IN ('mail_domain', 'mail_display_name') — combined bucket * * Used for per-type quota enforcement (separate Free/Pro/Legend limits for web vs. mail). */ export async function countActiveCustomDomainsSplit( userId: string, ): Promise<{ web: number; mail: number }> { const db = usePrisma(); const rows = await db.userCustomDomain.groupBy({ by: ["type"], where: { userId, status: { notIn: ["approved", "rejected"] } }, _count: { _all: true }, }); let web = 0; let mail = 0; for (const row of rows) { if (row.type === "web") web = row._count._all; else if (row.type === "mail_domain" || row.type === "mail_display_name") mail += row._count._all; } return { web, mail }; } export async function addUserCustomDomain( userId: string, domain: string, source = "manual", type: CustomDomainType = "web", ) { const db = usePrisma(); return db.userCustomDomain.create({ data: { userId, domain, source, type }, select: { id: true, domain: true, type: true }, }); } /** * Gibt alle Display-Name-Patterns eines Users zurück. * Wird vor jedem Mail-Scan geladen und an classifyMail() übergeben (Layer 2.6). * * DSGVO: keine PII — User-eigene Heuristik-Patterns (z.B. "EXTRASPIN"). */ export async function getCustomMailDisplayNames(userId: string): Promise { const db = usePrisma(); const rows = await db.userCustomDomain.findMany({ where: { userId, type: "mail_display_name" }, select: { domain: true }, }); return rows.map((r) => r.domain); } export async function deleteUserCustomDomain(id: string, userId: string) { const db = usePrisma(); // Cannot delete submitted/approved domains (protect integrity) const existing = await db.userCustomDomain.findFirst({ where: { id, userId }, select: { status: true }, }); if (!existing) throw Object.assign(new Error("Not found"), { code: "P2025" }); if (existing.status === "submitted" || existing.status === "approved") { throw Object.assign( new Error( "Eingereichte oder genehmigte Domains können nicht gelöscht werden", ), { code: "DOMAIN_LOCKED" }, ); } return db.userCustomDomain.delete({ where: { id, userId } }); } export async function deleteAllUserCustomDomains(userId: string) { const db = usePrisma(); return db.userCustomDomain.deleteMany({ where: { userId } }); } // ─── Domain Submissions ─────────────────────────────────────────────────────── // Net-Vote-Schwelle (yesVotes - noVotes) damit eine Submission von der // Community-Vote-Phase in die Admin-Review-Phase wandert. export const NET_VOTE_THRESHOLD = 10; // Submission-Status: // "pending" → Community-Voting-Phase (nur Pro) // "in_review" → wartet auf Admin (Legend direkt, Pro nach 10 Netto-Yes-Votes) // "approved" → in globaler Blocklist // "rejected" → vom Admin abgelehnt export type SubmissionPlan = "free" | "pro" | "legend"; export async function submitDomainForReview( userId: string, customDomainId: string, plan: SubmissionPlan, postId?: string, ) { if (plan === "free") { throw Object.assign(new Error("Free-Plan kann keine Domains einreichen"), { code: "PLAN_NO_SUBMIT", }); } const submissionStatus = plan === "legend" ? "in_review" : "pending"; const db = usePrisma(); return db.$transaction(async (tx) => { // 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, type: true }, }); // Create submission record — type aus CustomDomain kopieren const submission = await tx.domainSubmission.create({ data: { userId, domain: domain.domain, customDomainId, postId: postId ?? null, status: submissionStatus, type: domain.type, }, }); return { domain, submission }; }); } export async function castDomainVote( userId: string, submissionId: string, vote: "yes" | "no", ) { const db = usePrisma(); return db.$transaction(async (tx) => { // Upsert vote (change allowed) const existing = await tx.domainVote.findUnique({ where: { userId_submissionId: { userId, submissionId } }, }); if (existing) { if (existing.vote === vote) return { changed: false }; await tx.domainVote.update({ where: { userId_submissionId: { userId, submissionId } }, data: { vote }, }); } else { await tx.domainVote.create({ data: { userId, submissionId, vote } }); } // Recount const [yes, no] = await Promise.all([ tx.domainVote.count({ where: { submissionId, vote: "yes" } }), tx.domainVote.count({ where: { submissionId, vote: "no" } }), ]); const updated = await tx.domainSubmission.update({ where: { id: submissionId }, data: { yesVotes: yes, noVotes: no }, }); // Netto-Yes (yes - no) muss die Schwelle erreichen, dann wandert die // Submission in die Admin-Review-Phase. Approval erfolgt erst durch Admin. let movedToReview = false; if (updated.status === "pending" && yes - no >= NET_VOTE_THRESHOLD) { await tx.domainSubmission.update({ where: { id: submissionId }, data: { status: "in_review" }, }); movedToReview = true; } return { yesVotes: yes, noVotes: no, movedToReview }; }); } async function approveDomainSubmissionTx( tx: any, submissionId: string, customDomainId: string, domain: string, ) { await tx.domainSubmission.update({ where: { id: submissionId }, data: { status: "approved", reviewedAt: new Date() }, }); await tx.userCustomDomain.update({ where: { id: customDomainId }, data: { status: "approved" }, }); // Add to global blocklist await tx.blocklistDomain.upsert({ where: { domain }, create: { domain, source: "community", isActive: true }, update: { isActive: true }, }); } export async function adminApproveSubmission( submissionId: string, reviewNote?: string, ) { const db = usePrisma(); const sub = await db.domainSubmission.findUniqueOrThrow({ where: { id: submissionId }, select: { customDomainId: true, domain: true, type: true, status: true, userId: true, postId: true, }, }); // Admin darf in beiden offenen Phasen approven (Vote oder Review) if (sub.status !== "pending" && sub.status !== "in_review") { throw new Error("Submission already resolved"); } await db.$transaction((tx) => approveDomainSubmissionTx(tx, submissionId, sub.customDomainId, sub.domain), ); // Alle anderen User die diese Domain als Custom Domain haben → Slot freigeben + benachrichtigen const affectedDomains = await db.userCustomDomain.findMany({ where: { domain: sub.domain, // Submitter's domain already handled in approveDomainSubmissionTx id: { not: sub.customDomainId }, status: { notIn: ["approved", "rejected"] }, }, select: { id: true, userId: true }, }); if (affectedDomains.length > 0) { // Batch-Update: alle auf "approved" setzen (Slot freigeben) await db.userCustomDomain.updateMany({ where: { id: { in: affectedDomains.map((d) => d.id) } }, data: { status: "approved" }, }); // Notification an jeden betroffenen User await Promise.allSettled( affectedDomains.map((d) => createNotification({ recipientId: d.userId, type: "domain_accepted", actorName: "ReBreak", postId: sub.postId ?? undefined, preview: sub.domain, }), ), ); } // Auch Submitter benachrichtigen (Admin hat seine Domain genehmigt) await createNotification({ recipientId: sub.userId, type: "domain_accepted", actorName: "ReBreak Admin", postId: sub.postId ?? undefined, preview: sub.domain, }).catch(() => {}); return db.domainSubmission.findUnique({ where: { id: submissionId }, select: { id: true, domain: true, type: true, userId: true, postId: true }, }); } export async function adminRejectSubmission( submissionId: string, reviewNote?: string, ) { const db = usePrisma(); // Submission + zugehörige Custom-Domain laden (für Notification + Cleanup) const sub = await db.domainSubmission.findUniqueOrThrow({ where: { id: submissionId }, select: { userId: true, domain: true, postId: true, customDomainId: true, }, }); await db.$transaction(async (tx) => { await tx.domainSubmission.update({ where: { id: submissionId }, data: { status: "rejected", reviewedAt: new Date(), reviewNote: reviewNote ?? null, }, }); // Custom-Domain des Submitters komplett aus seiner Liste entfernen // (DomainSubmission via onDelete: Cascade automatisch mitgelöscht? NEIN — // Submission referenziert customDomain. Wir haben Submission gerade upgedatet, // also sicher löschen via Cascade von customDomain → submission.) await tx.userCustomDomain.delete({ where: { id: sub.customDomainId }, }); }); // Submitter benachrichtigen — Notification von ReBreak (System) await createNotification({ recipientId: sub.userId, type: "domain_rejected", actorName: "ReBreak", postId: sub.postId ?? undefined, preview: sub.domain, }).catch(() => {}); return { customDomainId: sub.customDomainId }; } // 24 Stunden in Millisekunden — SLA für Admin-Approval. // Legend-Requests sollen erst recht innerhalb dieser Frist beantwortet werden. 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; createdAt: Date; userId: string; postId: string | null; customDomain: { id: string } | null; user: { id: string; nickname: string | null; plan: string } | null; /** Plan-Priority — 2 = legend, 1 = pro, 0 = free/unknown */ planPriority: number; /** ISO-deadline = createdAt + 24h. Wenn jetzt > deadline → overdue. */ deadlineAt: Date; /** ms bis Deadline (negativ wenn überfällig) */ msUntilDeadline: number; }; /** * Pending Domain-Submissions für Admin-Review. * * Sort-Order: * 1. Legend zuerst (plan === "legend" hat höchste Priorität) * 2. Innerhalb gleicher Plan-Priority: älteste createdAt zuerst (FIFO) * * Postgres kann nach computed plan-priority nicht direkt orderBy'en, deswegen * sortieren wir post-fetch in JS — Liste ist klein (Admin-Review-Queue). * * Pro Row wird `deadlineAt` (createdAt + 24h) berechnet damit das Frontend * den Countdown / Overdue-Badge ohne Re-Computation zeigen kann. */ export async function getPendingSubmissions(): Promise { const db = usePrisma(); const rows = await db.domainSubmission.findMany({ where: { status: { in: ["pending", "in_review"] } }, orderBy: [{ createdAt: "asc" }], select: { id: true, domain: true, type: true, yesVotes: true, noVotes: true, status: true, createdAt: true, userId: true, postId: true, customDomain: { select: { id: true } }, user: { select: { id: true, nickname: true, plan: true } }, }, }); const now = Date.now(); const enriched = rows.map((r) => { const plan = r.user?.plan ?? "free"; const planPriority = plan === "legend" ? 2 : plan === "pro" ? 1 : 0; const deadlineAt = new Date(r.createdAt.getTime() + ADMIN_APPROVAL_SLA_MS); return { ...r, planPriority, deadlineAt, msUntilDeadline: deadlineAt.getTime() - now, }; }); // Legend (2) vor Pro (1) vor Free (0). Bei Gleichstand: ältere zuerst. enriched.sort((a, b) => { if (a.planPriority !== b.planPriority) { return b.planPriority - a.planPriority; } return a.createdAt.getTime() - b.createdAt.getTime(); }); return enriched; } // ─── Global Blocklist ───────────────────────────────────────────────────────── export async function getActiveBlocklistCount() { const db = usePrisma(); return db.blocklistDomain.count({ where: { isActive: true } }); } export async function getActiveBlocklistDomains() { const db = usePrisma(); return db.blocklistDomain.findMany({ where: { isActive: true }, select: { domain: true }, }); } export async function isBlocklistedDomain( domain: string, userId: string, ): Promise { const db = usePrisma(); const [global, custom] = await Promise.all([ db.blocklistDomain.findFirst({ where: { domain, isActive: true }, select: { domain: true }, }), db.userCustomDomain.findFirst({ where: { domain, userId }, select: { domain: true }, }), ]); return !!(global || custom); } export async function getBlocklistedDomainsSet( domains: string[], userId: string, includeGlobal = true, ): Promise> { if (domains.length === 0) return new Set(); const db = usePrisma(); const unique = [...new Set(domains)]; // Alle möglichen Parent-Domains erzeugen: "mail.casino.de" → ["mail.casino.de", "casino.de"] const allVariants = [ ...new Set( unique.flatMap((d) => { const parts = d.split("."); return parts .map((_, i) => parts.slice(i).join(".")) .filter((v) => v.includes(".")); }), ), ]; const queries: Promise<{ domain: string }[]>[] = []; if (includeGlobal) { queries.push( db.blocklistDomain.findMany({ where: { domain: { in: allVariants }, isActive: true }, select: { domain: true }, }), ); } queries.push( db.userCustomDomain.findMany({ where: { domain: { in: allVariants }, userId }, select: { domain: true }, }), ); const results = await Promise.all(queries); const blockedSet = new Set(results.flatMap((r) => r.map((d) => d.domain))); // Zurück auf Original-Domains mappen: wenn irgendein Variant geblockt ist → Original blocken const result = new Set(); for (const orig of unique) { const parts = orig.split("."); const variants = parts .map((_, i) => parts.slice(i).join(".")) .filter((v) => v.includes(".")); if (variants.some((v) => blockedSet.has(v))) result.add(orig); } return result; } export async function upsertBlocklistDomains( domains: { domain: string; source: string }[], ) { const db = usePrisma(); // Batch upsert in chunks const CHUNK = 5000; let total = 0; for (let i = 0; i < domains.length; i += CHUNK) { const chunk = domains.slice(i, i + CHUNK).map((d) => ({ ...d, isActive: true, })); for (const d of chunk) { await db.blocklistDomain.upsert({ where: { domain: d.domain }, create: d, update: { isActive: true }, }); total++; } } return total; }