diff --git a/backend/prisma/migrations/20260522_curated_domains/migration.sql b/backend/prisma/migrations/20260522_curated_domains/migration.sql new file mode 100644 index 0000000..6f80a52 --- /dev/null +++ b/backend/prisma/migrations/20260522_curated_domains/migration.sql @@ -0,0 +1,20 @@ +-- Migration: curated_domains +-- Länderspezifische Curated-Domain-Vorschläge für die VIP-Layer-2-Liste. +-- User schlagen Glücksspielseiten ihres Landes vor; status='approved'-Einträge +-- werden vom webcontent-domains-Endpoint zusätzlich zur statischen +-- gambling-domains.json komponiert. Approve/Reject erfolgt admin-seitig. + +CREATE TABLE "rebreak"."curated_domains" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "domain" TEXT NOT NULL, + "country" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'suggested', + "suggested_by_user_id" UUID, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "reviewed_at" TIMESTAMPTZ, + + CONSTRAINT "curated_domains_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "curated_domains_country_domain_key" ON "rebreak"."curated_domains"("country", "domain"); +CREATE INDEX "curated_domains_country_status_idx" ON "rebreak"."curated_domains"("country", "status"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 0913ee8..6ae8dbe 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -454,6 +454,25 @@ model UserCustomDomain { @@schema("rebreak") } +// Länderspezifische Curated-Domain-Vorschläge für die VIP-Layer-2-Liste. +// User schlagen Glücksspielseiten ihres Landes vor; `approved`-Einträge +// ergänzen im webcontent-domains-Endpoint die statische gambling-domains.json. +model CuratedDomain { + id String @id @default(uuid()) @db.Uuid + domain String + country String + // "suggested" | "approved" | "rejected" + status String @default("suggested") + suggestedByUserId String? @map("suggested_by_user_id") @db.Uuid + createdAt DateTime @default(now()) @map("created_at") + reviewedAt DateTime? @map("reviewed_at") + + @@unique([country, domain]) + @@index([country, status]) + @@map("curated_domains") + @@schema("rebreak") +} + model DomainSubmission { id String @id @default(uuid()) @db.Uuid userId String @map("user_id") @db.Uuid diff --git a/backend/server/api/curated-domains/suggest.post.ts b/backend/server/api/curated-domains/suggest.post.ts new file mode 100644 index 0000000..a65c297 --- /dev/null +++ b/backend/server/api/curated-domains/suggest.post.ts @@ -0,0 +1,55 @@ +import { usePrisma } from "../../utils/prisma"; + +// Domain muss mindestens eine TLD haben (z.B. "mbet216.com"). +const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/; +// Unterstützte VIP-Länder — muss zu COUNTRY_KEYS in webcontent-domains.get.ts passen. +const VALID_COUNTRIES = ["DE", "GB", "FR", "TN"]; + +/** + * POST /api/curated-domains/suggest + * + * Ein User schlägt eine länderspezifische Glücksspielseite für die kuratierte + * VIP-Layer-2-Liste seines Landes vor. Der Eintrag landet als `suggested` in + * der curated_domains-Tabelle; ein ReBreak-Admin gibt ihn frei (`approved`) — + * erst dann komponiert ihn der webcontent-domains-Endpoint in die Länderliste. + * + * Body: { domain: string, country: string (ISO-2) } + */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const body = await readBody(event); + + // Domain normalisieren — lowercase, Schema/Pfad/www. strippen. + const domain = (typeof body?.domain === "string" ? body.domain : "") + .trim() + .toLowerCase() + .replace(/^https?:\/\//, "") + .replace(/\/.*$/, "") + .replace(/^www\./, ""); + const country = + typeof body?.country === "string" ? body.country.toUpperCase() : ""; + + if (!domain || domain.length > 253 || !DOMAIN_RE.test(domain)) { + throw createError({ statusCode: 400, data: { error: "INVALID_DOMAIN" } }); + } + if (!VALID_COUNTRIES.includes(country)) { + throw createError({ statusCode: 400, data: { error: "INVALID_COUNTRY" } }); + } + + const db = usePrisma(); + + // Schon vorhanden (egal welcher Status)? Dann nicht doppelt anlegen. + const existing = await db.curatedDomain.findUnique({ + where: { country_domain: { country, domain } }, + select: { status: true }, + }); + if (existing) { + return { ok: false, alreadyExists: true, status: existing.status }; + } + + await db.curatedDomain.create({ + data: { domain, country, status: "suggested", suggestedByUserId: user.id }, + }); + + return { ok: true }; +}); diff --git a/backend/server/api/protection/webcontent-domains.get.ts b/backend/server/api/protection/webcontent-domains.get.ts index c75274f..695595c 100644 --- a/backend/server/api/protection/webcontent-domains.get.ts +++ b/backend/server/api/protection/webcontent-domains.get.ts @@ -1,5 +1,6 @@ import gamblingDomains from "../../data/gambling-domains.json"; import { getWebCustomDomains } from "../../db/domains"; +import { usePrisma } from "../../utils/prisma"; const COUNTRY_KEYS = ["DE", "GB", "FR", "TN"] as const; type CountryKey = (typeof COUNTRY_KEYS)[number]; @@ -51,6 +52,19 @@ export default defineEventHandler(async (event) => { // kuratierte Auffüllung wieder reinkommen (sie ist ja eine Top-Domain). const cappedCustomSet = new Set(cappedCustom); + // DB-approved Curated-Domains (User-Vorschläge, admin-freigegeben) ergänzen + // die statische gambling-domains.json pro Land — wichtig für Länder mit + // kurzer Starter-Liste (z.B. TN). + const db = usePrisma(); + const approvedCurated = await db.curatedDomain.findMany({ + where: { status: "approved" }, + select: { domain: true, country: true }, + }); + const curatedByCountry: Record = {}; + for (const c of approvedCurated) { + (curatedByCountry[c.country] ??= []).push(c.domain); + } + // Pro Country: Custom-Domains vorne, dann globale Auffüllung, dedup, cap 50 const composed: Record = {} as Record< CountryKey, @@ -58,7 +72,13 @@ export default defineEventHandler(async (event) => { >; for (const country of COUNTRY_KEYS) { - const globalList: string[] = GLOBAL_LISTS[country] ?? []; + // statische JSON-Liste + DB-approved Curated des Landes, dedupliziert + const globalList: string[] = [ + ...new Set([ + ...(GLOBAL_LISTS[country] ?? []), + ...(curatedByCountry[country] ?? []), + ]), + ]; // Gekappte Custom-Domains zuerst (bereits dedupliziert da aus DB) const merged: string[] = [...cappedCustom];