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
|
postId String? @map("post_id") @db.Uuid
|
||||||
addedAt DateTime @default(now()) @map("added_at")
|
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?
|
submission DomainSubmission?
|
||||||
|
|
||||||
@@unique([userId, domain])
|
@@unique([userId, domain])
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { awardPoints } from "../../utils/scoring";
|
|||||||
import {
|
import {
|
||||||
addUserCustomDomain,
|
addUserCustomDomain,
|
||||||
countActiveCustomDomains,
|
countActiveCustomDomains,
|
||||||
|
getWebCustomDomains,
|
||||||
CUSTOM_DOMAIN_TYPES,
|
CUSTOM_DOMAIN_TYPES,
|
||||||
type CustomDomainType,
|
type CustomDomainType,
|
||||||
} from "../../db/domains";
|
} 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 CURATED_LISTS = gamblingDomains as unknown as Record<string, string[]>;
|
||||||
const VIP_COUNTRIES = ["DE", "GB", "FR"] as const;
|
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. */
|
/** Client-`country` (Geräte-Region) → unterstützter VIP-Ländercode. Fallback DE. */
|
||||||
function resolveVipCountry(raw: unknown): string {
|
function resolveVipCountry(raw: unknown): string {
|
||||||
const c = typeof raw === "string" ? raw.toUpperCase() : "";
|
const c = typeof raw === "string" ? raw.toUpperCase() : "";
|
||||||
@ -279,6 +287,22 @@ export default defineEventHandler(async (event) => {
|
|||||||
if (webAddAsApproved) {
|
if (webAddAsApproved) {
|
||||||
return { ...data, addedToVip: true };
|
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;
|
return data;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const msg =
|
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[]> {
|
export async function getWebCustomDomains(userId: string): Promise<string[]> {
|
||||||
const db = usePrisma();
|
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)
|
// pending = alles außer approved/rejected — älteste zuerst (passen alle rein)
|
||||||
const pending = await db.userCustomDomain.findMany({
|
const pending = await db.userCustomDomain.findMany({
|
||||||
where: { userId, type: "web", status: { notIn: ["approved", "rejected"] } },
|
where: { userId, type: "web", status: { notIn: ["approved", "rejected"] } },
|
||||||
orderBy: { addedAt: "asc" },
|
orderBy: { addedAt: "asc" },
|
||||||
select: { domain: true },
|
select: { domain: true, vipDeferUntil: true, vipEvictAt: true },
|
||||||
});
|
});
|
||||||
// approved — neueste zuerst, damit bei Cap-Überlauf die ältesten wegfallen
|
// approved — neueste zuerst, damit bei Cap-Überlauf die ältesten wegfallen
|
||||||
const approved = await db.userCustomDomain.findMany({
|
const approved = await db.userCustomDomain.findMany({
|
||||||
where: { userId, type: "web", status: "approved" },
|
where: { userId, type: "web", status: "approved" },
|
||||||
orderBy: { addedAt: "desc" },
|
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) {
|
export async function getUserCustomDomains(userId: string) {
|
||||||
@ -65,6 +73,8 @@ export async function getUserCustomDomains(userId: string) {
|
|||||||
type: true,
|
type: true,
|
||||||
postId: true,
|
postId: true,
|
||||||
addedAt: true,
|
addedAt: true,
|
||||||
|
vipDeferUntil: true,
|
||||||
|
vipEvictAt: true,
|
||||||
submission: {
|
submission: {
|
||||||
select: { id: true, yesVotes: true, noVotes: true, status: true },
|
select: { id: true, yesVotes: true, noVotes: true, status: true },
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user