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:
parent
708eac51c0
commit
93eb3aceec
@ -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;
|
||||
@ -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])
|
||||
|
||||
@ -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 =
|
||||
|
||||
63
backend/server/api/custom-domains/vip-swap.post.ts
Normal file
63
backend/server/api/custom-domains/vip-swap.post.ts
Normal 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() };
|
||||
});
|
||||
@ -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 },
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user