import { usePrisma } from "../utils/prisma"; import { createNotification } from "./notifications"; // ─── 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, 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) */ export async function countActiveCustomDomains(userId: string) { const db = usePrisma(); return db.userCustomDomain.count({ where: { userId, status: { notIn: ["approved", "rejected"] } }, }); } export async function addUserCustomDomain( userId: string, domain: string, source = "manual", ) { const db = usePrisma(); return db.userCustomDomain.create({ data: { userId, domain, source }, select: { id: true, domain: true }, }); } 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 const domain = await tx.userCustomDomain.update({ where: { id: customDomainId, userId }, data: { status: "submitted", postId: postId ?? null }, select: { id: true, domain: true }, }); // Create submission record const submission = await tx.domainSubmission.create({ data: { userId, domain: domain.domain, customDomainId, postId: postId ?? null, status: submissionStatus, }, }); 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, 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 } }); } 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 }; } export async function getPendingSubmissions() { const db = usePrisma(); return db.domainSubmission.findMany({ where: { status: { in: ["pending", "in_review"] } }, orderBy: [{ status: "asc" }, { yesVotes: "desc" }], select: { id: true, domain: true, yesVotes: true, noVotes: true, status: true, createdAt: true, userId: true, postId: true, customDomain: { select: { id: true } }, }, }); } // ─── 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; }