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>
This commit is contained in:
chahinebrini 2026-05-22 20:05:44 +02:00
parent 708eac51c0
commit 93eb3aceec
5 changed files with 127 additions and 3 deletions

View File

@ -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;

View File

@ -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])

View File

@ -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<string, string[]>;
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 =

View File

@ -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() };
});

View File

@ -38,19 +38,27 @@ export const CUSTOM_DOMAIN_TYPES: CustomDomainType[] = [
*/
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 },
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 },
},