chahinebrini 93eb3aceec feat(vip): VIP-Slot-Replace Backend — Swap mit 24h-Cooldown
Wenn die VIP-Liste (Layer 2) voll ist (>30 eigene Web-Domains) und der
User eine neue Custom-Domain hinzufügt, ersetzt er bewusst eine
bestehende — der Tausch greift in der VIP erst nach 24h Cooldown.

- Schema: UserCustomDomain.vipDeferUntil + vipEvictAt
  (Migration 20260522_add_vip_swap_fields, additiv + nullable)
- getWebCustomDomains: filtert deferred (noch nicht in VIP) + evicted
  (Cooldown durch → raus) — lazy ausgewertet, kein Cron
- POST /api/custom-domains: neue Web-Domain über dem 30er-Cap → wird
  zurückgestellt (vipDeferUntil gesetzt), Response-Flag vipFull
- POST /api/custom-domains/vip-swap: setzt effectiveAt = jetzt+24h auf
  neue + ersetzte Domain
- Layer 1 bleibt unberührt — die neue Domain ist dort sofort aktiv

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 20:05:44 +02:00

606 lines
19 KiB
TypeScript

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 ───────────────────────────────────────────────────────────
/**
* Web-Custom-Domains eines Users für die Layer-2-VIP-Komposition (type='web').
* Nur 'rejected' wird ausgeschlossen — 'approved' Domains BLEIBEN in der VIP:
* Layer 2 ist der Zweitschutz für den Fall, dass Layer 1 (VPN/URL-Filter) aus
* ist. Eine approved Domain ist zwar in der globalen Layer-1-Blocklist, muss
* aber auch in Layer 2 gedeckt sein.
*
* Reihenfolge = Priorität für den 50er-Cap im Endpoint:
* 1. pending zuerst — KEINE Layer-1-Deckung, die VIP ist ihre einzige
* Absicherung → dürfen nie aus dem Cap fallen (≤ Slot-Limit, passen immer).
* 2. approved danach, neueste zuerst — bei Überlauf fallen die ältesten
* approved weg (via Layer 1 weiter gedeckt, daher vertretbar).
*
* Wird von GET /api/protection/webcontent-domains genutzt.
*/
export async function getWebCustomDomains(userId: string): Promise<string[]> {
const db = usePrisma();
const now = new Date();
// VIP-Sichtbarkeit (VIP-Slot-Replace): eine Domain mit `vipDeferUntil` in der
// Zukunft ist noch NICHT in der VIP (Swap-Cooldown läuft); eine mit
// `vipEvictAt` in der Vergangenheit ist aus der VIP RAUS.
const inVip = (r: { vipDeferUntil: Date | null; vipEvictAt: Date | null }) =>
!(r.vipDeferUntil && r.vipDeferUntil > now) &&
!(r.vipEvictAt && r.vipEvictAt <= now);
// pending = alles außer approved/rejected — älteste zuerst (passen alle rein)
const pending = await db.userCustomDomain.findMany({
where: { userId, type: "web", status: { notIn: ["approved", "rejected"] } },
orderBy: { addedAt: "asc" },
select: { domain: true, vipDeferUntil: true, vipEvictAt: true },
});
// approved — neueste zuerst, damit bei Cap-Überlauf die ältesten wegfallen
const approved = await db.userCustomDomain.findMany({
where: { userId, type: "web", status: "approved" },
orderBy: { addedAt: "desc" },
select: { domain: true, vipDeferUntil: true, vipEvictAt: true },
});
return [...pending, ...approved].filter(inVip).map((r) => r.domain);
}
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,
vipDeferUntil: true,
vipEvictAt: true,
submission: {
select: { id: true, yesVotes: true, noVotes: true, status: true },
},
},
});
return rows;
}
/**
* Zählt die Domains, die einen Slot belegen (active + submitted).
* approved → Slot frei (Domain in globale Liste aufgenommen)
* rejected → Slot frei (User kann neu einreichen)
*
* Single Source of Truth für das Slot-Limit — web + mail teilen sich EINEN
* gemeinsamen Pool (Pro 10 / Legend 20).
*/
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",
status = "active",
) {
const db = usePrisma();
return db.userCustomDomain.create({
data: { userId, domain, source, type, status },
select: { id: true, domain: true, type: true, status: 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<string[]> {
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<PendingSubmissionRow[]> {
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<boolean> {
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<Set<string>> {
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<string>();
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;
}