From 93eb3aceec18c36acfe6495a6d01922636a99843 Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Fri, 22 May 2026 20:05:44 +0200 Subject: [PATCH] =?UTF-8?q?feat(vip):=20VIP-Slot-Replace=20Backend=20?= =?UTF-8?q?=E2=80=94=20Swap=20mit=2024h-Cooldown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../migration.sql | 19 ++++++ backend/prisma/schema.prisma | 8 +++ .../server/api/custom-domains/index.post.ts | 24 +++++++ .../api/custom-domains/vip-swap.post.ts | 63 +++++++++++++++++++ backend/server/db/domains.ts | 16 ++++- 5 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 backend/prisma/migrations/20260522_add_vip_swap_fields/migration.sql create mode 100644 backend/server/api/custom-domains/vip-swap.post.ts diff --git a/backend/prisma/migrations/20260522_add_vip_swap_fields/migration.sql b/backend/prisma/migrations/20260522_add_vip_swap_fields/migration.sql new file mode 100644 index 0000000..0295f3a --- /dev/null +++ b/backend/prisma/migrations/20260522_add_vip_swap_fields/migration.sql @@ -0,0 +1,19 @@ +-- VIP-Slot-Replace: Cooldown-Zeitstempel für den 24h-Swap der VIP-Liste (Layer 2). +-- +-- Wenn die VIP-Liste voll ist und der User eine neue Custom-Domain hinzufügt, +-- ersetzt er bewusst eine bestehende eigene Domain. Der Tausch greift in der +-- VIP-Liste (Layer 2) erst nach 24h Cooldown — Layer 1 schützt die neue Domain +-- sofort. +-- +-- vip_defer_until — die neu hinzugefügte Domain ist erst ab diesem Zeitpunkt +-- Teil der VIP-Layer-2-Liste. +-- vip_evict_at — die zum Ersetzen gewählte Domain fällt ab diesem Zeitpunkt +-- aus der VIP-Liste. +-- +-- Beide NULL = normaler Zustand (kein laufender Swap). Backfill: alle +-- bestehenden Zeilen bekommen NULL — keine Verhaltensänderung. + +ALTER TABLE "rebreak"."user_custom_domains" + ADD COLUMN IF NOT EXISTS "vip_defer_until" TIMESTAMPTZ NULL; +ALTER TABLE "rebreak"."user_custom_domains" + ADD COLUMN IF NOT EXISTS "vip_evict_at" TIMESTAMPTZ NULL; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index c0f25d7..0913ee8 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -438,6 +438,14 @@ model UserCustomDomain { postId String? @map("post_id") @db.Uuid addedAt DateTime @default(now()) @map("added_at") + // VIP-Slot-Replace (Layer-2-Swap mit 24h-Cooldown): + // vipDeferUntil — die NEUE Domain ist erst ab hier Teil der VIP-Liste + // (während des Cooldowns nur via Layer 1 geschützt). + // vipEvictAt — die ERSETZTE Domain fällt ab hier aus der VIP-Liste. + // Beide NULL = kein laufender Swap. + vipDeferUntil DateTime? @map("vip_defer_until") + vipEvictAt DateTime? @map("vip_evict_at") + submission DomainSubmission? @@unique([userId, domain]) diff --git a/backend/server/api/custom-domains/index.post.ts b/backend/server/api/custom-domains/index.post.ts index 44673fa..038eefe 100644 --- a/backend/server/api/custom-domains/index.post.ts +++ b/backend/server/api/custom-domains/index.post.ts @@ -2,6 +2,7 @@ import { awardPoints } from "../../utils/scoring"; import { addUserCustomDomain, countActiveCustomDomains, + getWebCustomDomains, CUSTOM_DOMAIN_TYPES, type CustomDomainType, } from "../../db/domains"; @@ -17,6 +18,13 @@ const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9]) const CURATED_LISTS = gamblingDomains as unknown as Record; const VIP_COUNTRIES = ["DE", "GB", "FR"] as const; +// Die VIP-Layer-2-Liste fasst max. 50 Domains; 20 davon sind für die +// kuratierte Liste reserviert (RESERVED_CURATED in webcontent-domains.get.ts) +// → max. 30 eigene Custom-Domains. Wird die überschritten, greift der +// VIP-Slot-Replace-Flow (Swap mit 24h-Cooldown). +const MAX_VIP_CUSTOM = 30; +const SWAP_COOLDOWN_MS = 24 * 60 * 60 * 1000; + /** Client-`country` (Geräte-Region) → unterstützter VIP-Ländercode. Fallback DE. */ function resolveVipCountry(raw: unknown): string { const c = typeof raw === "string" ? raw.toUpperCase() : ""; @@ -279,6 +287,22 @@ export default defineEventHandler(async (event) => { if (webAddAsApproved) { return { ...data, addedToVip: true }; } + + // VIP-Slot-Replace: bringt die neue Web-Domain die VIP-Liste (Layer 2) + // über ihr 30er-Cap, wird sie zunächst zurückgestellt (vipDeferUntil) — + // der User wählt dann im Swap-Dialog, welche eigene Domain sie ersetzt. + // Layer 1 schützt die neue Domain bereits ab sofort. + if (type === "web") { + const vipDomains = await getWebCustomDomains(user.id); + if (vipDomains.length > MAX_VIP_CUSTOM) { + await db.userCustomDomain.update({ + where: { id: data.id }, + data: { vipDeferUntil: new Date(Date.now() + SWAP_COOLDOWN_MS) }, + }); + return { ...data, vipFull: true }; + } + } + return data; } catch (err: any) { const msg = diff --git a/backend/server/api/custom-domains/vip-swap.post.ts b/backend/server/api/custom-domains/vip-swap.post.ts new file mode 100644 index 0000000..4a775e7 --- /dev/null +++ b/backend/server/api/custom-domains/vip-swap.post.ts @@ -0,0 +1,63 @@ +import { usePrisma } from "../../utils/prisma"; + +// 24h-Cooldown — identisch zu SWAP_COOLDOWN_MS in index.post.ts. +const SWAP_COOLDOWN_MS = 24 * 60 * 60 * 1000; + +/** + * POST /api/custom-domains/vip-swap + * + * VIP-Slot-Replace: die VIP-Liste (Layer 2) ist voll. Der User hat gerade eine + * neue Custom-Domain hinzugefügt (`newDomainId` — steht via `vipDeferUntil` + * bereits zurückgestellt) und wählt jetzt eine seiner EIGENEN Domains + * (`evictedDomainId`), die sie ersetzt. + * + * Beide bekommen denselben `effectiveAt` = jetzt + 24h: + * - die ersetzte Domain fällt dann aus der VIP-Liste (`vipEvictAt`), + * - die neue Domain kommt dann rein (`vipDeferUntil`). + * Layer 1 bleibt für beide unberührt — die neue Domain ist dort sofort aktiv. + */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const body = await readBody(event); + const newDomainId = + typeof body?.newDomainId === "string" ? body.newDomainId : ""; + const evictedDomainId = + typeof body?.evictedDomainId === "string" ? body.evictedDomainId : ""; + + if (!newDomainId || !evictedDomainId) { + throw createError({ statusCode: 400, data: { error: "MISSING_IDS" } }); + } + if (newDomainId === evictedDomainId) { + throw createError({ statusCode: 400, data: { error: "SAME_DOMAIN" } }); + } + + const db = usePrisma(); + // Beide Domains müssen dem User gehören und web-Typ sein. + const [newDomain, evicted] = await Promise.all([ + db.userCustomDomain.findFirst({ + where: { id: newDomainId, userId: user.id, type: "web" }, + select: { id: true }, + }), + db.userCustomDomain.findFirst({ + where: { id: evictedDomainId, userId: user.id, type: "web" }, + select: { id: true }, + }), + ]); + if (!newDomain || !evicted) { + throw createError({ statusCode: 404, data: { error: "DOMAIN_NOT_FOUND" } }); + } + + const effectiveAt = new Date(Date.now() + SWAP_COOLDOWN_MS); + await db.$transaction([ + db.userCustomDomain.update({ + where: { id: newDomainId }, + data: { vipDeferUntil: effectiveAt }, + }), + db.userCustomDomain.update({ + where: { id: evictedDomainId }, + data: { vipEvictAt: effectiveAt }, + }), + ]); + + return { ok: true, effectiveAt: effectiveAt.toISOString() }; +}); diff --git a/backend/server/db/domains.ts b/backend/server/db/domains.ts index 5bc72c1..3f6279b 100644 --- a/backend/server/db/domains.ts +++ b/backend/server/db/domains.ts @@ -38,19 +38,27 @@ export const CUSTOM_DOMAIN_TYPES: CustomDomainType[] = [ */ export async function getWebCustomDomains(userId: string): Promise { 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 }, + 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 }, + select: { domain: true, vipDeferUntil: true, vipEvictAt: true }, }); - return [...pending.map((r) => r.domain), ...approved.map((r) => r.domain)]; + return [...pending, ...approved].filter(inVip).map((r) => r.domain); } export async function getUserCustomDomains(userId: string) { @@ -65,6 +73,8 @@ export async function getUserCustomDomains(userId: string) { type: true, postId: true, addedAt: true, + vipDeferUntil: true, + vipEvictAt: true, submission: { select: { id: true, yesVotes: true, noVotes: true, status: true }, },