From 335945fe2caa3a02dca2866335691e6e69e08ad6 Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Mon, 11 May 2026 16:23:02 +0200 Subject: [PATCH] feat(tier): plan limits Rev.2 + downgrade reconciliation + change-preview (Phase 2 backend) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - plan-features.ts: globalBlocklist 'curated'|'full' (curated = 30-domain stub, TODO real ~1-2k HaGeZi subset); maxAppDevices vs maxProtectedDevices split (legend maxProtectedDevices: 2); mail 1/3/Infinity - limit-enforcement structured errors on mail/connect, custom-domains/add, devices/enroll ({ error:'plan_limit', resource, current, limit }); approved-own-submissions already excluded from custom-domain count (slot frees on approval) - server/utils/downgrade-reconciliation.ts: founding-member exemption; re-upgrade reactivates paused mail + degraded devices; downgrade pauses newest-N mail accounts (isActive=false, pausedAt, pausedReason; pre-pause sets nextScanAt=now for a final sweep — real direct IMAP scan is TODO/stub); degrades excess device profiles (status='degraded', degradedAt); free → globalBlocklistGraceUntil = now+14d; custom domains grandfathered - set-plan.post.ts + stripe/webhook.post.ts: run reconciliation on plan change; set-plan accepts { foundingMember } for testing - GET /api/plan/change-preview?to=: gains/keeps/changes per resource (8 axes), founding-member → direction 'same' - me.get.ts: + foundingMember, globalBlocklistGraceUntil, planLimits block - blocklist + mail-scan honour globalBlocklistGraceUntil (grace → treat as 'full') - db: countMailConnections/getMailConnections exclude paused; getAllMailConnections; getDeviceBlocklistMode (active|grace|passthrough|revoked) - migration 20260511_tier_system_phase2 (profiles.founding_member + global_blocklist_grace_until; mail_connections.paused_at/paused_reason; protected_devices.degraded_at). prisma generate + build:backend clean. TODOs (separate tickets): founding-member auto-counter on signup; real direct IMAP final-scan (not just nextScanAt nudge); real curated blocklist data + wiring the stub into the blocklist response for free users. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../20260511_tier_system_phase2/migration.sql | 17 + backend/prisma/schema.prisma | 20 +- backend/server/api/auth/me.get.ts | 29 +- .../server/api/custom-domains/index.post.ts | 7 +- backend/server/api/dev/set-plan.post.ts | 49 ++- backend/server/api/devices/enroll.post.ts | 17 +- backend/server/api/devices/index.get.ts | 2 +- backend/server/api/devices/register.post.ts | 6 +- backend/server/api/mail/connect.post.ts | 7 +- backend/server/api/mail/scan-internal.post.ts | 7 +- backend/server/api/mail/scan.post.ts | 6 +- backend/server/api/plan/change-preview.get.ts | 363 ++++++++++++++++++ backend/server/api/stripe/webhook.post.ts | 36 +- .../api/url-filter/blocklist.bin.get.ts | 11 +- backend/server/db/mail.ts | 31 +- backend/server/db/protectedDevices.ts | 98 +++-- backend/server/utils/auth.ts | 4 +- .../server/utils/downgrade-reconciliation.ts | 189 +++++++++ backend/server/utils/plan-features.ts | 93 ++++- 19 files changed, 911 insertions(+), 81 deletions(-) create mode 100644 backend/prisma/migrations/20260511_tier_system_phase2/migration.sql create mode 100644 backend/server/api/plan/change-preview.get.ts create mode 100644 backend/server/utils/downgrade-reconciliation.ts diff --git a/backend/prisma/migrations/20260511_tier_system_phase2/migration.sql b/backend/prisma/migrations/20260511_tier_system_phase2/migration.sql new file mode 100644 index 0000000..9d86d25 --- /dev/null +++ b/backend/prisma/migrations/20260511_tier_system_phase2/migration.sql @@ -0,0 +1,17 @@ +-- Migration: tier_system_phase2 +-- Downgrade-Reconciliation, Founding-Members, GlobalBlocklist-Grace. +-- Neue Felder auf Profile, MailConnection, ProtectedDevice. + +-- Profile: founding_member flag + global blocklist grace +ALTER TABLE "rebreak"."profiles" + ADD COLUMN IF NOT EXISTS "founding_member" BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS "global_blocklist_grace_until" TIMESTAMPTZ; + +-- MailConnection: pause-state für Downgrade-Reconciliation +ALTER TABLE "rebreak"."mail_connections" + ADD COLUMN IF NOT EXISTS "paused_at" TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS "paused_reason" TEXT; + +-- ProtectedDevice: degraded-state für 14-Tage-Grace nach Downgrade +ALTER TABLE "rebreak"."protected_devices" + ADD COLUMN IF NOT EXISTS "degraded_at" TIMESTAMPTZ; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 62ce727..0b06a3b 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -61,6 +61,17 @@ model Profile { voiceSecondsUsedToday Int @default(0) @map("voice_seconds_used_today") voiceQuotaResetAt DateTime? @map("voice_quota_reset_at") + // ─── Founding Members (erste 100 Signups → automatisch Pro, lifetime) ──── + // foundingMember=true → exempt von Downgrade-Reconciliation (ihr Pro ist Geschenk). + // Wird beim Signup gesetzt wenn profile-count < 100. + // Manuell setzbar via /api/dev/set-plan (für Testing). + foundingMember Boolean @default(false) @map("founding_member") + + // ─── Globale Blocklist Grace-Period (nach Downgrade auf free) ──────────── + // Wenn gesetzt: User sieht noch die volle Blocklist bis zu diesem Datum. + // Nach Ablauf: nur noch kuratierte Kernliste. 14-Tage-Grace. + globalBlocklistGraceUntil DateTime? @map("global_blocklist_grace_until") + // ─── Admin-Management (Phase E, Migration 20260509) ───────────────────── // banned: User wird auf API-Ebene blockiert (kein Login-Block — Supabase // bleibt unberührt). Soft-Delete scrubbt PII statt Hard-Delete (DSGVO). @@ -475,6 +486,10 @@ model MailConnection { useStarttls Boolean @default(false) @map("use_starttls") passwordEncrypted String @map("password_encrypted") isActive Boolean @default(true) @map("is_active") + /// Wenn gesetzt: Account wurde durch Downgrade-Reconciliation pausiert (nicht gelöscht). + /// Re-Upgrade setzt pausedAt=null + pausedReason=null + isActive=true zurück. + pausedAt DateTime? @map("paused_at") + pausedReason String? @map("paused_reason") // z.B. "plan_downgrade" scanInterval Int @default(24) @map("scan_interval") lastScannedAt DateTime? @map("last_scanned_at") nextScanAt DateTime? @map("next_scan_at") @@ -723,12 +738,15 @@ model ProtectedDevice { platform String /// User-friendly label, z.B. "MacBook Pro" oder "Olfas iPhone" label String - /// pending (enrolled, profile not installed yet) | active (user confirmed install) | revoked + /// pending (enrolled, profile not installed yet) | active (user confirmed install) | degraded (plan downgrade — Grace läuft) | revoked status String @default("pending") /// User confirmed install via App (not server-side verified yet — DoH-routing kommt in Phase 2) installedAt DateTime? @map("installed_at") /// Optional: DoH-server pingt das später (Phase 2, separater Sprint) lastDnsQueryAt DateTime? @map("last_dns_query_at") + /// Gesetzt wenn Plan-Downgrade das Gerät auf degraded setzt. Nach 14d Passthrough. + /// Bei Re-Upgrade: zurück auf active, degradedAt=null. + degradedAt DateTime? @map("degraded_at") revokedAt DateTime? @map("revoked_at") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/backend/server/api/auth/me.get.ts b/backend/server/api/auth/me.get.ts index d49d40c..332d405 100644 --- a/backend/server/api/auth/me.get.ts +++ b/backend/server/api/auth/me.get.ts @@ -1,21 +1,40 @@ import { getProfile } from "../../db/profile"; +import { getPlanLimits } from "../../utils/plan-features"; export default defineEventHandler(async (event) => { const user = await requireUser(event); const dbProfile = await getProfile(user.id); + const plan = (dbProfile?.plan === "premium" + ? "legend" + : dbProfile?.plan === "standard" + ? "pro" + : dbProfile?.plan ?? "free") as "free" | "pro" | "legend"; + + const limits = getPlanLimits(plan); + return { id: user.id, email: user.email, username: dbProfile?.username ?? "", nickname: dbProfile?.nickname ?? null, avatar: dbProfile?.avatar ?? null, - plan: (dbProfile?.plan === "premium" - ? "legend" - : dbProfile?.plan === "standard" - ? "pro" - : dbProfile?.plan ?? "free") as "free" | "pro" | "legend", + plan, + foundingMember: dbProfile?.foundingMember ?? false, streak: dbProfile?.streak ?? 0, created_at: dbProfile?.createdAt?.toISOString() ?? user.created_at, + // Für useUserPlan im Frontend — Key-Subset der PlanLimits + planLimits: { + customDomains: limits.customDomains, + domainRefill: limits.domainRefill, + mailAgents: limits.mailAgents === Infinity ? null : limits.mailAgents, + globalBlocklist: limits.globalBlocklist, + maxAppDevices: limits.maxAppDevices, + maxProtectedDevices: limits.maxProtectedDevices, + canCreateGroup: limits.canCreateGroup, + canAddToBlocklist: limits.canAddToBlocklist, + }, + globalBlocklistGraceUntil: + dbProfile?.globalBlocklistGraceUntil?.toISOString() ?? null, }; }); diff --git a/backend/server/api/custom-domains/index.post.ts b/backend/server/api/custom-domains/index.post.ts index c40c665..d0e6fe8 100644 --- a/backend/server/api/custom-domains/index.post.ts +++ b/backend/server/api/custom-domains/index.post.ts @@ -29,7 +29,12 @@ export default defineEventHandler(async (event) => { if (activeCount >= limits.customDomains) { throw createError({ statusCode: 403, - message: `Dein Plan erlaubt maximal ${limits.customDomains} eigene Domains`, + data: { + error: "plan_limit", + resource: "custom_domains", + current: activeCount, + limit: limits.customDomains, + }, }); } } diff --git a/backend/server/api/dev/set-plan.post.ts b/backend/server/api/dev/set-plan.post.ts index ef51963..5adf5c9 100644 --- a/backend/server/api/dev/set-plan.post.ts +++ b/backend/server/api/dev/set-plan.post.ts @@ -1,4 +1,6 @@ import { usePrisma } from "../../utils/prisma"; +import { runDowngradeReconciliation } from "../../utils/downgrade-reconciliation"; +import type { Plan } from "../../utils/plan-features"; const VALID_PLANS = ["free", "pro", "legend"] as const; type AppPlan = (typeof VALID_PLANS)[number]; @@ -9,8 +11,8 @@ type AppPlan = (typeof VALID_PLANS)[number]; * DEV/STAGING-ONLY: Setzt den eigenen Plan ohne Admin-Rechte. * Blocked in Production (appUrl enthält "rebreak.org" aber NICHT "staging"). * - * Body: { plan: "free" | "pro" | "legend" } - * Response: { success: true, plan: AppPlan } + * Body: { plan: "free" | "pro" | "legend", foundingMember?: boolean } + * Response: { success: true, plan: AppPlan, foundingMember: boolean, reconciled: boolean } */ export default defineEventHandler(async (event) => { const user = await requireUser(event); @@ -26,6 +28,7 @@ export default defineEventHandler(async (event) => { const body = await readBody(event).catch(() => ({})); const plan = body?.plan as string | undefined; + const setFoundingMember = body?.foundingMember as boolean | undefined; if (!plan || !(VALID_PLANS as readonly string[]).includes(plan)) { throw createError({ @@ -38,10 +41,46 @@ export default defineEventHandler(async (event) => { } const db = usePrisma(); - await db.profile.update({ + + // Aktuellen Plan lesen für Reconciliation + const current = await db.profile.findUnique({ where: { id: user.id }, - data: { plan: plan as AppPlan }, + select: { plan: true, foundingMember: true }, }); - return { success: true, plan: plan as AppPlan }; + const fromPlan = (current?.plan ?? "free") as Plan; + const toPlan = plan as AppPlan; + + // Plan + optional foundingMember setzen + const updateData: Record = { plan: toPlan }; + if (typeof setFoundingMember === "boolean") { + updateData.foundingMember = setFoundingMember; + } + + await db.profile.update({ + where: { id: user.id }, + data: updateData, + }); + + // Downgrade-Reconciliation (überspringt automatisch wenn foundingMember=true) + let reconciled = false; + try { + await runDowngradeReconciliation(user.id, fromPlan, toPlan); + reconciled = true; + } catch (err) { + // Reconciliation-Fehler darf Plan-Wechsel nicht blockieren + console.error("[set-plan] reconciliation error:", err); + } + + const updated = await db.profile.findUnique({ + where: { id: user.id }, + select: { foundingMember: true }, + }); + + return { + success: true, + plan: toPlan, + foundingMember: updated?.foundingMember ?? false, + reconciled, + }; }); diff --git a/backend/server/api/devices/enroll.post.ts b/backend/server/api/devices/enroll.post.ts index 6881621..95c5465 100644 --- a/backend/server/api/devices/enroll.post.ts +++ b/backend/server/api/devices/enroll.post.ts @@ -1,5 +1,6 @@ import { randomBytes } from "crypto"; import { getProfile } from "../../db/profile"; +import { getPlanLimits } from "../../utils/plan-features"; import { countActiveProtectedDevices, createProtectedDevice, @@ -19,7 +20,10 @@ export default defineEventHandler(async (event) => { const user = await requireUser(event); const profile = await getProfile(user.id); - if (profile?.plan !== "legend") { + const limits = getPlanLimits(profile?.plan ?? "free"); + + // maxProtectedDevices=0 → Feature nicht verfügbar (free/pro) + if (limits.maxProtectedDevices === 0) { throw createError({ statusCode: 403, data: { error: "LEGEND_REQUIRED" }, @@ -42,12 +46,17 @@ export default defineEventHandler(async (event) => { } const trimmedLabel = label.trim().slice(0, 100); - // Limit: max 3 active+pending Devices + // Limit: max. maxProtectedDevices active+pending Devices const activeCount = await countActiveProtectedDevices(user.id); - if (activeCount >= 3) { + if (activeCount >= limits.maxProtectedDevices) { throw createError({ statusCode: 409, - data: { error: "DEVICE_LIMIT_REACHED", max: 3, current: activeCount }, + data: { + error: "plan_limit", + resource: "protected_devices", + current: activeCount, + limit: limits.maxProtectedDevices, + }, }); } diff --git a/backend/server/api/devices/index.get.ts b/backend/server/api/devices/index.get.ts index b907e5d..23bbe67 100644 --- a/backend/server/api/devices/index.get.ts +++ b/backend/server/api/devices/index.get.ts @@ -23,7 +23,7 @@ export default defineEventHandler(async (event) => { ...d, isCurrent: !!currentDeviceId && d.deviceId === currentDeviceId, })), - max: limits.maxDevices, + max: limits.maxAppDevices, plan: profile?.plan ?? "free", }; }); diff --git a/backend/server/api/devices/register.post.ts b/backend/server/api/devices/register.post.ts index 39a9961..1af093f 100644 --- a/backend/server/api/devices/register.post.ts +++ b/backend/server/api/devices/register.post.ts @@ -42,9 +42,9 @@ export default defineEventHandler(async (event) => { platform, model: model ?? null, name: name ?? null, - maxDevices: limits.maxDevices, + maxDevices: limits.maxAppDevices, }); - return { device, created, max: limits.maxDevices }; + return { device, created, max: limits.maxAppDevices }; } catch (err: any) { if (err.code === "DEVICE_LIMIT_REACHED") { const devices = await listUserDevices(user.id); @@ -53,7 +53,7 @@ export default defineEventHandler(async (event) => { statusMessage: "device_limit_reached", data: { error: "device_limit_reached", - max: limits.maxDevices, + max: limits.maxAppDevices, plan: profile?.plan ?? "free", devices, }, diff --git a/backend/server/api/mail/connect.post.ts b/backend/server/api/mail/connect.post.ts index e311931..0b60c42 100644 --- a/backend/server/api/mail/connect.post.ts +++ b/backend/server/api/mail/connect.post.ts @@ -37,7 +37,12 @@ export default defineEventHandler(async (event) => { if (count >= limits.mailAgents) { throw createError({ statusCode: 403, - message: `Dein Plan erlaubt maximal ${limits.mailAgents} Mail-Agent${limits.mailAgents !== 1 ? "en" : ""}`, + data: { + error: "plan_limit", + resource: "mail_accounts", + current: count, + limit: limits.mailAgents, + }, }); } } diff --git a/backend/server/api/mail/scan-internal.post.ts b/backend/server/api/mail/scan-internal.post.ts index 1006381..4bcd619 100644 --- a/backend/server/api/mail/scan-internal.post.ts +++ b/backend/server/api/mail/scan-internal.post.ts @@ -35,9 +35,14 @@ export default defineEventHandler(async (event) => { if (connections.length === 0) return { scanned: 0, blocked: 0 }; // Plan-aware blocklist + // Grace-Period: wenn globalBlocklistGraceUntil noch in der Zukunft liegt, + // behandeln wir den User als 'full' auch wenn sein Plan 'curated' sagt. const profile = await getProfile(userId); const limits = getPlanLimits(profile?.plan ?? "free"); - const includeGlobal = limits.globalBlocklist; + const inGrace = + profile?.globalBlocklistGraceUntil != null && + new Date(profile.globalBlocklistGraceUntil) > new Date(); + const includeGlobal = limits.globalBlocklist === "full" || inGrace; await deleteOldMailBlocked(userId); diff --git a/backend/server/api/mail/scan.post.ts b/backend/server/api/mail/scan.post.ts index f1defce..6c45e4d 100644 --- a/backend/server/api/mail/scan.post.ts +++ b/backend/server/api/mail/scan.post.ts @@ -33,7 +33,11 @@ export default defineEventHandler(async (event) => { // Plan-aware: Free users get only custom domains, Pro/Legend get global blocklist const profile = await getProfile(user.id); const limits = getPlanLimits(profile?.plan ?? "free"); - const includeGlobal = limits.globalBlocklist; + // Grace-Period berücksichtigen + const inGrace = + profile?.globalBlocklistGraceUntil != null && + new Date(profile.globalBlocklistGraceUntil) > new Date(); + const includeGlobal = limits.globalBlocklist === "full" || inGrace; await deleteOldMailBlocked(user.id); diff --git a/backend/server/api/plan/change-preview.get.ts b/backend/server/api/plan/change-preview.get.ts new file mode 100644 index 0000000..deda045 --- /dev/null +++ b/backend/server/api/plan/change-preview.get.ts @@ -0,0 +1,363 @@ +import { getProfile } from "../../db/profile"; +import { getPlanLimits, type Plan } from "../../utils/plan-features"; +import { usePrisma } from "../../utils/prisma"; + +/** + * GET /api/plan/change-preview?to= + * + * Zeigt dem User vorab was sich bei einem Plan-Wechsel ändert. + * Frontend baut den Briefing-Screen (§4, pricing-tiers.md) gegen diesen Endpoint. + * + * Response-Shape: siehe types unten. + */ + +type ResourceKey = + | "global_blocklist" + | "custom_domains" + | "mail_accounts" + | "protected_devices" + | "coach" + | "tts" + | "voice_picker" + | "group_creation"; + +interface ChangeEntry { + resource: ResourceKey; + current: number | string; + newLimit: number | string; + overBy: number; + action: "keep" | "limited" | "paused" | "grace_then_off" | "degraded" | "unlocked"; + detail: string; + graceUntilDays?: number; +} + +interface ChangePreviewResponse { + from: Plan; + to: Plan; + direction: "upgrade" | "downgrade" | "same"; + gains: string[]; + keeps: string[]; + changes: ChangeEntry[]; +} + +const VALID_PLANS: Plan[] = ["free", "pro", "legend"]; +const PLAN_ORDER: Record = { free: 0, pro: 1, legend: 2 }; + +export default defineEventHandler(async (event): Promise => { + const user = await requireUser(event); + + const query = getQuery(event); + const toPlan = query.to as string | undefined; + + if (!toPlan || !VALID_PLANS.includes(toPlan as Plan)) { + throw createError({ + statusCode: 400, + data: { + error: "INVALID_PLAN", + message: `to must be one of: ${VALID_PLANS.join(", ")}`, + }, + }); + } + + const profile = await getProfile(user.id); + const rawPlan = profile?.plan ?? "free"; + const fromPlan = (rawPlan === "premium" + ? "legend" + : rawPlan === "standard" + ? "pro" + : rawPlan) as Plan; + const to = toPlan as Plan; + + const isFoundingMember = profile?.foundingMember ?? false; + + const direction: "upgrade" | "downgrade" | "same" = + PLAN_ORDER[to] > PLAN_ORDER[fromPlan] + ? "upgrade" + : PLAN_ORDER[to] < PLAN_ORDER[fromPlan] + ? "downgrade" + : "same"; + + // Founding Member verliert nichts — leere changes + if (isFoundingMember && direction === "downgrade") { + return { + from: fromPlan, + to, + direction: "same", + gains: [], + keeps: [ + "Dein Streak, deine Logs, dein Coach", + "Dein bisheriger Schutz — alles bleibt", + "Als Founding Member bleibt dein Plan dauerhaft erhalten", + ], + changes: [], + }; + } + + const fromLimits = getPlanLimits(fromPlan); + const toLimits = getPlanLimits(to); + + // ── Aktuelle Ressourcen-Counts laden ────────────────────────────────────── + const db = usePrisma(); + + const [ + activeMailCount, + activeDomainCount, + activeDeviceCount, + ] = await Promise.all([ + db.mailConnection.count({ where: { userId: user.id, isActive: true, pausedAt: null } }), + db.userCustomDomain.count({ + where: { userId: user.id, status: { notIn: ["approved", "rejected"] } }, + }), + db.protectedDevice.count({ + where: { userId: user.id, status: { in: ["active", "pending"] } }, + }), + ]); + + const changes: ChangeEntry[] = []; + const gains: string[] = []; + const keeps: string[] = [ + "Dein Streak, deine Logs, dein Coach — alles bleibt", + "Nichts wird gelöscht. Alles Pausierte kommt sofort zurück wenn du wieder upgradest", + ]; + + // ── Global Blocklist ─────────────────────────────────────────────────────── + if (fromLimits.globalBlocklist !== toLimits.globalBlocklist) { + if (direction === "downgrade") { + // full → curated + changes.push({ + resource: "global_blocklist", + current: "volle Liste (~208.000 Domains)", + newLimit: "kuratierte Kernliste (~1.000 Domains)", + overBy: 0, + action: "grace_then_off", + detail: + "Du hast noch 14 Tage Zugang zur vollen Blocklist. Danach sind deine " + + "eigenen Domains weiter aktiv — trag jetzt deine wichtigsten ein.", + graceUntilDays: 14, + }); + } else { + // curated → full + gains.push("Volle Glücksspiel-Blocklist (~208.000 bekannte Domains)"); + } + } else if (direction !== "same") { + // Gleicher Wert — keine Änderung nötig, aber zum Kontext nennen + } + + // ── Custom Domains ──────────────────────────────────────────────────────── + if (fromLimits.customDomains !== toLimits.customDomains) { + if (direction === "downgrade") { + const newLimit = toLimits.customDomains; + const overBy = Math.max(0, activeDomainCount - newLimit); + changes.push({ + resource: "custom_domains", + current: activeDomainCount, + newLimit, + overBy, + action: "keep", // grandfathered — alle bleiben aktiv + detail: + overBy > 0 + ? `Du hast ${activeDomainCount} eigene Domains, ${to}-Plan erlaubt ${newLimit}. ` + + `Alle bleiben aktiv — du kannst erst wieder welche hinzufügen wenn du unter ${newLimit} bist.` + : `Du hast ${activeDomainCount} von ${toLimits.customDomains} möglichen Domains — kein Überlauf.`, + }); + } else { + gains.push( + `Bis zu ${toLimits.customDomains} eigene Domains${toLimits.domainRefill ? " (Slots füllen sich auf wenn deine Domain in die globale Liste aufgenommen wird)" : ""}`, + ); + } + } + + // ── Domain Refill ───────────────────────────────────────────────────────── + if (!fromLimits.domainRefill && toLimits.domainRefill && direction === "upgrade") { + gains.push( + "Domain-Slot-Refill: wenn ReBreak eine deiner eingereichten Domains genehmigt, wird der Slot frei", + ); + } + + // ── Mail-Accounts ───────────────────────────────────────────────────────── + const newMailLimit = toLimits.mailAgents; + if (fromLimits.mailAgents !== newMailLimit) { + if (direction === "downgrade" && newMailLimit !== Infinity) { + const overBy = Math.max(0, activeMailCount - newMailLimit); + if (overBy > 0) { + changes.push({ + resource: "mail_accounts", + current: activeMailCount, + newLimit: newMailLimit, + overBy, + action: "paused", + detail: + `Du hast ${activeMailCount} verbundene Postfächer, ${to}-Plan schützt ${newMailLimit}. ` + + `Die ${overBy} zuletzt hinzugefügten werden pausiert — nicht gelöscht. ` + + "Ein letzter Scan läuft noch durch bevor sie pausiert werden. " + + "Bei Re-Upgrade kommen sie sofort zurück.", + }); + } else { + changes.push({ + resource: "mail_accounts", + current: activeMailCount, + newLimit: newMailLimit, + overBy: 0, + action: "keep", + detail: `Du hast ${activeMailCount} von ${newMailLimit} möglichen Postfächern — kein Überlauf.`, + }); + } + } else if (direction === "upgrade") { + const limitText = newMailLimit === Infinity ? "unbegrenzt" : String(newMailLimit); + gains.push( + newMailLimit === Infinity + ? "Unbegrenzt viele Mail-Postfächer schützen (Echtzeit-Scan geplant)" + : `Bis zu ${limitText} Mail-Postfächer`, + ); + } + } + + // ── Protected Devices (Mac/Windows DNS-Profile) ─────────────────────────── + if (fromLimits.maxProtectedDevices !== toLimits.maxProtectedDevices) { + if (direction === "downgrade" && toLimits.maxProtectedDevices === 0) { + const overBy = activeDeviceCount; + if (overBy > 0) { + changes.push({ + resource: "protected_devices", + current: activeDeviceCount, + newLimit: 0, + overBy, + action: "degraded", + detail: + `Du hast ${activeDeviceCount} geschützte(s) Gerät(e). Diese laufen noch 14 Tage auf voller ` + + "Blocklist weiter. Danach liefert der DNS-Filter für diese Geräte keinen Schutz mehr — " + + "das Profil bleibt auf dem Gerät (entferne es manuell unter System-Einstellungen). " + + "Bei Re-Upgrade auf Legend: sofort wieder voll aktiv.", + graceUntilDays: 14, + }); + } + } else if (direction === "upgrade") { + gains.push( + `Bis zu ${toLimits.maxProtectedDevices} weitere Geräte (Mac/Windows) per DNS-Profil schützen`, + ); + } + } + + // ── Coach ───────────────────────────────────────────────────────────────── + if (fromLimits.aiModel !== toLimits.aiModel) { + if (direction === "downgrade") { + changes.push({ + resource: "coach", + current: friendlyModelName(fromLimits.aiModel), + newLimit: friendlyModelName(toLimits.aiModel), + overBy: 0, + action: "limited", + detail: + "Dein Coach läuft ab jetzt auf einem anderen Modell — er ist weiter da, immer.", + }); + } else { + gains.push(`Lyra läuft auf ${friendlyModelName(toLimits.aiModel)} — feinfühligere Gespräche`); + } + } + + // ── TTS ─────────────────────────────────────────────────────────────────── + if ( + fromLimits.voice.provider !== toLimits.voice.provider || + fromLimits.voice.dailyQuotaSeconds !== toLimits.voice.dailyQuotaSeconds + ) { + if (direction === "downgrade") { + const fromQuota = + fromLimits.voice.dailyQuotaSeconds === 0 + ? "unbegrenzt" + : `${fromLimits.voice.dailyQuotaSeconds / 60} Min/Tag`; + const toQuota = + toLimits.voice.dailyQuotaSeconds === 0 + ? "unbegrenzt" + : `${toLimits.voice.dailyQuotaSeconds / 60} Min/Tag`; + changes.push({ + resource: "tts", + current: `${friendlyProviderName(fromLimits.voice.provider)}, ${fromQuota}`, + newLimit: `${friendlyProviderName(toLimits.voice.provider)}, ${toQuota}`, + overBy: 0, + action: "limited", + detail: + `Sprachausgabe wechselt auf ${friendlyProviderName(toLimits.voice.provider)} (${toQuota}). ` + + "Deine gewählte Stimme merken wir uns — bei Re-Upgrade sofort wieder aktiv.", + }); + } else { + const toQuota = + toLimits.voice.dailyQuotaSeconds === 0 + ? "unbegrenzt" + : `${toLimits.voice.dailyQuotaSeconds / 60} Min/Tag`; + gains.push( + `${friendlyProviderName(toLimits.voice.provider)} Sprachausgabe (${toQuota})`, + ); + } + } + + // ── Voice Picker (Legend-only) ──────────────────────────────────────────── + if (fromPlan === "legend" && to !== "legend" && direction === "downgrade") { + changes.push({ + resource: "voice_picker", + current: "Stimme wählbar", + newLimit: "Standard-Stimme", + overBy: 0, + action: "limited", + detail: + "Die Lyra-Stimme wird auf Standard zurückgesetzt. Deine Auswahl bleibt gespeichert — " + + "bei Re-Upgrade sofort wieder aktiv.", + }); + } else if (to === "legend" && fromPlan !== "legend" && direction === "upgrade") { + gains.push("Lyra-Stimme frei wählbar"); + } + + // ── Group Creation (Legend-only) ────────────────────────────────────────── + if (fromLimits.canCreateGroup && !toLimits.canCreateGroup && direction === "downgrade") { + changes.push({ + resource: "group_creation", + current: "Gruppen gründen erlaubt", + newLimit: "Keine neuen Gruppen", + overBy: 0, + action: "keep", // bestehende Gruppen bleiben (grandfathered) + detail: + "Bestehende Gruppen bleiben — du bleibst Admin. " + + "Neue Gruppen gründen geht erst wieder ab Legend.", + }); + } else if (!fromLimits.canCreateGroup && toLimits.canCreateGroup && direction === "upgrade") { + gains.push("Eigene Community-Gruppen gründen"); + } + + // ── Same-Plan: alles leer ───────────────────────────────────────────────── + if (direction === "same") { + return { + from: fromPlan, + to, + direction: "same", + gains: [], + keeps, + changes: [], + }; + } + + return { + from: fromPlan, + to, + direction, + gains, + keeps, + changes, + }; +}); + +function friendlyModelName(model: string): string { + if (model.includes("claude-3.5-haiku") || model.includes("claude-3-haiku")) return "Claude (Haiku)"; + if (model.includes("llama-3.3-70b")) return "Llama 70B"; + if (model.includes("llama-3.1-8b")) return "Llama 8B"; + return model; +} + +function friendlyProviderName(provider: string): string { + const map: Record = { + elevenlabs: "ElevenLabs", + cartesia: "Cartesia", + google: "Google", + openai: "OpenAI", + azure: "Azure", + }; + return map[provider] ?? provider; +} diff --git a/backend/server/api/stripe/webhook.post.ts b/backend/server/api/stripe/webhook.post.ts index 4dc932b..941e5cb 100644 --- a/backend/server/api/stripe/webhook.post.ts +++ b/backend/server/api/stripe/webhook.post.ts @@ -1,10 +1,12 @@ import Stripe from "stripe"; import { usePrisma } from "../../utils/prisma"; +import { runDowngradeReconciliation } from "../../utils/downgrade-reconciliation"; +import type { Plan } from "../../utils/plan-features"; /** * POST /api/stripe/webhook * Stripe Webhook – verarbeitet Subscription-Events. - * Aktualisiert profiles.plan + stripe_* Felder. + * Aktualisiert profiles.plan + stripe_* Felder + triggert Downgrade-Reconciliation. */ export default defineEventHandler(async (event) => { const config = useRuntimeConfig(); @@ -41,17 +43,29 @@ export default defineEventHandler(async (event) => { const session = stripeEvent.data.object as Stripe.Checkout.Session; const userId = session.metadata?.user_id || session.client_reference_id; const plan = session.metadata?.plan || "legend"; + const newPlan = ( + plan === "legend" ? "legend" : plan === "pro" ? "pro" : "free" + ) as Plan; if (userId) { + const current = await db.profile.findUnique({ + where: { id: userId }, + select: { plan: true }, + }); + const fromPlan = (current?.plan ?? "free") as Plan; + await db.profile.update({ where: { id: userId }, data: { - plan: - plan === "legend" ? "legend" : plan === "pro" ? "pro" : "free", + plan: newPlan, stripeCustomerId: session.customer as string, stripeSubId: session.subscription as string, }, }); + + await runDowngradeReconciliation(userId, fromPlan, newPlan).catch( + (err) => console.error("[stripe-webhook] reconciliation error:", err), + ); } break; } @@ -67,15 +81,22 @@ export default defineEventHandler(async (event) => { if (profile) { const isActive = ["active", "trialing"].includes(sub.status); + const newPlan = (isActive ? profile.plan : "free") as Plan; + const fromPlan = profile.plan as Plan; + await db.profile.update({ where: { id: profile.id }, data: { - plan: isActive ? profile.plan : "free", + plan: newPlan, premiumUntil: sub.current_period_end ? new Date(sub.current_period_end * 1000) : null, }, }); + + await runDowngradeReconciliation(profile.id, fromPlan, newPlan).catch( + (err) => console.error("[stripe-webhook] reconciliation error:", err), + ); } break; } @@ -86,14 +107,19 @@ export default defineEventHandler(async (event) => { const profile = await db.profile.findFirst({ where: { stripeCustomerId: customerId }, - select: { id: true }, + select: { id: true, plan: true }, }); if (profile) { + const fromPlan = profile.plan as Plan; await db.profile.update({ where: { id: profile.id }, data: { plan: "free", premiumUntil: null }, }); + + await runDowngradeReconciliation(profile.id, fromPlan, "free").catch( + (err) => console.error("[stripe-webhook] reconciliation error:", err), + ); } break; } diff --git a/backend/server/api/url-filter/blocklist.bin.get.ts b/backend/server/api/url-filter/blocklist.bin.get.ts index 9348b64..ed39d1d 100644 --- a/backend/server/api/url-filter/blocklist.bin.get.ts +++ b/backend/server/api/url-filter/blocklist.bin.get.ts @@ -38,8 +38,15 @@ export default defineEventHandler(async (event) => { const limits = getPlanLimits(profile?.plan ?? "free"); - // Global Domains nur für Pro/Legend - const global = limits.globalBlocklist ? await getActiveBlocklistDomains() : []; + // Grace-Period: wenn globalBlocklistGraceUntil noch in der Zukunft liegt, + // behandeln wir den User als 'full' auch wenn sein Plan 'curated' sagt. + const inGrace = + profile?.globalBlocklistGraceUntil != null && + new Date(profile.globalBlocklistGraceUntil) > new Date(); + const useFullBlocklist = limits.globalBlocklist === "full" || inGrace; + + // Global Domains nur für Pro/Legend (oder während Grace-Period) + const global = useFullBlocklist ? await getActiveBlocklistDomains() : []; // Beide Listen ohne Salt hashen — vereinfachte Architektur: // Server kennt die Klartext-Domains eh (via DB), darum bringt User-Salt diff --git a/backend/server/db/mail.ts b/backend/server/db/mail.ts index c9ae815..beb5d46 100644 --- a/backend/server/db/mail.ts +++ b/backend/server/db/mail.ts @@ -2,12 +2,38 @@ import { usePrisma } from "../utils/prisma"; export async function getMailConnections(userId: string) { const db = usePrisma(); + // isActive=true UND nicht pausiert (pausedAt=null) — pausierte werden vom Cron ausgelassen return db.mailConnection.findMany({ - where: { userId, isActive: true }, + where: { userId, isActive: true, pausedAt: null }, orderBy: { createdAt: "asc" }, }); } +/** Alle Verbindungen eines Users inkl. pausierten — für Status-Anzeige im Frontend. */ +export async function getAllMailConnections(userId: string) { + const db = usePrisma(); + return db.mailConnection.findMany({ + where: { userId }, + orderBy: { createdAt: "asc" }, + select: { + id: true, + email: true, + provider: true, + providerName: true, + isActive: true, + pausedAt: true, + pausedReason: true, + scanInterval: true, + lastScannedAt: true, + nextScanAt: true, + emailsBlocked: true, + emailsScanned: true, + lastConnectError: true, + createdAt: true, + }, + }); +} + export async function getAllActiveMailUserIds() { const db = usePrisma(); const rows = await db.mailConnection.findMany({ @@ -20,7 +46,8 @@ export async function getAllActiveMailUserIds() { export async function countMailConnections(userId: string) { const db = usePrisma(); - return db.mailConnection.count({ where: { userId, isActive: true } }); + // Nur aktive + nicht-pausierte Verbindungen zählen gegen das Limit + return db.mailConnection.count({ where: { userId, isActive: true, pausedAt: null } }); } export async function upsertMailConnection(data: { diff --git a/backend/server/db/protectedDevices.ts b/backend/server/db/protectedDevices.ts index 0e2e90a..bced3eb 100644 --- a/backend/server/db/protectedDevices.ts +++ b/backend/server/db/protectedDevices.ts @@ -6,6 +6,7 @@ export interface ProtectedDeviceRecord { label: string; status: string; installedAt: Date | null; + degradedAt: Date | null; createdAt: Date; } @@ -14,6 +15,22 @@ export interface ProtectedDeviceWithToken extends ProtectedDeviceRecord { userId: string; } +const DEVICE_SELECT = { + id: true, + platform: true, + label: true, + status: true, + installedAt: true, + degradedAt: true, + createdAt: true, +} as const; + +const DEVICE_SELECT_WITH_TOKEN = { + ...DEVICE_SELECT, + dnsToken: true, + userId: true, +} as const; + /** Alle nicht-revoked Devices eines Users, neueste zuerst. */ export async function listProtectedDevices( userId: string, @@ -22,18 +39,11 @@ export async function listProtectedDevices( return db.protectedDevice.findMany({ where: { userId, status: { not: "revoked" } }, orderBy: { createdAt: "desc" }, - select: { - id: true, - platform: true, - label: true, - status: true, - installedAt: true, - createdAt: true, - }, + select: DEVICE_SELECT, }); } -/** Anzahl der aktiven+pending Devices für Limit-Check. */ +/** Anzahl der aktiven+pending Devices für Limit-Check (degraded zählt NICHT — Slot freigegeben). */ export async function countActiveProtectedDevices( userId: string, ): Promise { @@ -50,16 +60,18 @@ export async function getProtectedDevice( const db = usePrisma(); return db.protectedDevice.findUnique({ where: { id }, - select: { - id: true, - userId: true, - dnsToken: true, - platform: true, - label: true, - status: true, - installedAt: true, - createdAt: true, - }, + select: DEVICE_SELECT_WITH_TOKEN, + }); +} + +/** Lookup by dnsToken — für DoH-Blocklist-Endpoint (Token aus URL). */ +export async function getProtectedDeviceByToken( + dnsToken: string, +): Promise { + const db = usePrisma(); + return db.protectedDevice.findUnique({ + where: { dnsToken }, + select: DEVICE_SELECT_WITH_TOKEN, }); } @@ -79,16 +91,7 @@ export async function createProtectedDevice(opts: { label: opts.label, status: "pending", }, - select: { - id: true, - userId: true, - dnsToken: true, - platform: true, - label: true, - status: true, - installedAt: true, - createdAt: true, - }, + select: DEVICE_SELECT_WITH_TOKEN, }); } @@ -109,14 +112,7 @@ export async function confirmProtectedDeviceInstalled( status: "active", installedAt: new Date(), }, - select: { - id: true, - platform: true, - label: true, - status: true, - installedAt: true, - createdAt: true, - }, + select: DEVICE_SELECT, }); } @@ -137,3 +133,31 @@ export async function revokeProtectedDevice( }); return true; } + +/** + * Prüft ob ein Token nach der 14-Tage-Grace-Period in Passthrough-Modus ist. + * Wird vom DoH-Blocklist-Endpoint aufgerufen. + * + * Returns: + * 'active' → volle Blocklist liefern + * 'grace' → volle Blocklist liefern (innerhalb 14-Tage-Grace) + * 'passthrough' → nur minimale/leere Liste liefern + * 'revoked' → Token unbekannt oder revoked → Passthrough + */ +export async function getDeviceBlocklistMode( + dnsToken: string, +): Promise<"active" | "grace" | "passthrough" | "revoked"> { + const device = await getProtectedDeviceByToken(dnsToken); + if (!device) return "revoked"; + if (device.status === "revoked") return "revoked"; + if (device.status === "active" || device.status === "pending") return "active"; + if (device.status === "degraded") { + const GRACE_MS = 14 * 24 * 60 * 60 * 1000; + const gracedAt = device.degradedAt + ? device.degradedAt.getTime() + GRACE_MS + : 0; + if (Date.now() <= gracedAt) return "grace"; + return "passthrough"; + } + return "passthrough"; +} diff --git a/backend/server/utils/auth.ts b/backend/server/utils/auth.ts index 461e7d5..39e5d9e 100644 --- a/backend/server/utils/auth.ts +++ b/backend/server/utils/auth.ts @@ -73,7 +73,7 @@ export async function requireUser( userId: user.id, deviceId, platform, - maxDevices: limits.maxDevices, + maxDevices: limits.maxAppDevices, }); return user; } catch (err: any) { @@ -88,7 +88,7 @@ export async function requireUser( statusMessage: 'device_limit_reached', data: { error: 'device_limit_reached', - max: limits.maxDevices, + max: limits.maxAppDevices, plan: profile?.plan ?? 'free', devices, }, diff --git a/backend/server/utils/downgrade-reconciliation.ts b/backend/server/utils/downgrade-reconciliation.ts new file mode 100644 index 0000000..af6dd7a --- /dev/null +++ b/backend/server/utils/downgrade-reconciliation.ts @@ -0,0 +1,189 @@ +import { getPlanLimits, type Plan } from "./plan-features"; +import { usePrisma } from "./prisma"; + +/** + * Downgrade-Reconciliation — führt alle notwendigen Ressourcen-Anpassungen + * durch wenn ein User seinen Plan wechselt. + * + * WICHTIG: Diese Funktion ist idempotent. Sie kann mehrfach aufgerufen + * werden ohne Schaden (Re-Upgrades werden korrekt reversiert). + * + * §3 Downgrade-Policy (pricing-tiers.md): + * - Custom-Domains: grandfathered, kein Neues bis unter Limit + * - Mail-Accounts: überzählige pausieren (älteste behalten, neueste pausieren) + * Special: letzter Scan über bald-pausierte Accounts VOR dem Pausieren + * - Protected Devices: status=degraded + degradedAt setzen (14-Tage-Grace) + * - Global Blocklist: globalBlocklistGraceUntil setzen (14-Tage) bei free-Downgrade + * - Re-Upgrade: alle pausierten Mail-Accounts aktivieren, degraded Devices reactivieren + * + * foundingMember-Exemption: wenn true → kein Reconciliation (Geschenk-Plan bleibt). + */ +export async function runDowngradeReconciliation( + userId: string, + fromPlan: Plan, + toPlan: Plan, +): Promise { + const db = usePrisma(); + + // Founding-Member-Check: exempt von Reconciliation + const profile = await db.profile.findUnique({ + where: { id: userId }, + select: { foundingMember: true }, + }); + if (profile?.foundingMember) { + return; // Founding Members sind von Downgrade-Reconciliation ausgenommen + } + + const fromLimits = getPlanLimits(fromPlan); + const toLimits = getPlanLimits(toPlan); + + const planOrder: Plan[] = ["free", "pro", "legend"]; + const isDowngrade = + planOrder.indexOf(toPlan) < planOrder.indexOf(fromPlan); + const isUpgrade = + planOrder.indexOf(toPlan) > planOrder.indexOf(fromPlan); + + if (isUpgrade) { + await reconcileUpgrade(userId, db); + return; + } + + if (!isDowngrade) return; // same plan — no-op + + // ── 1. Mail-Accounts ────────────────────────────────────────────────────── + await reconcileMailAccounts(userId, toLimits.mailAgents, db); + + // ── 2. Protected Devices (nur relevant wenn legend → pro/free) ──────────── + if (fromLimits.maxProtectedDevices > 0 && toLimits.maxProtectedDevices === 0) { + await reconcileProtectedDevices(userId, db); + } + + // ── 3. Global Blocklist Grace (nur wenn full → curated, also → free) ────── + if (fromLimits.globalBlocklist === "full" && toLimits.globalBlocklist === "curated") { + const graceUntil = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000); + await db.profile.update({ + where: { id: userId }, + data: { globalBlocklistGraceUntil: graceUntil }, + }); + } + + // Custom-Domains sind grandfathered — keine Aktion nötig. + // Die Limit-Prüfung in /api/custom-domains/index.post.ts blockiert das Hinzufügen. +} + +async function reconcileMailAccounts( + userId: string, + newLimit: number, + db: ReturnType, +): Promise { + if (newLimit === Infinity) return; // unbegrenzt → nichts zu pausieren + + // Alle aktiven Mail-Verbindungen, älteste zuerst (älteste behalten) + const connections = await db.mailConnection.findMany({ + where: { userId, isActive: true, pausedAt: null }, + orderBy: { createdAt: "asc" }, + }); + + if (connections.length <= newLimit) return; // kein Überlauf + + const toKeep = connections.slice(0, newLimit); + const toPause = connections.slice(newLimit); // neueste werden pausiert + + // §3 Spezialfall: letzter Scan über bald-pausierte Accounts triggern. + // Wir rufen scan-internal intern auf, ohne HTTP-Overhead. + // Falls der Scan fehlschlägt (z.B. IMAP-Fehler) pausieren wir trotzdem — Recovery > Sauberkeit. + for (const conn of toPause) { + try { + await triggerFinalScanForConnection(userId, conn.id); + } catch { + // intentionally swallowed — Pausieren danach sowieso + } + } + + const pausedAt = new Date(); + await db.mailConnection.updateMany({ + where: { id: { in: toPause.map((c) => c.id) } }, + data: { + isActive: false, + pausedAt, + pausedReason: "plan_downgrade", + }, + }); + + void toKeep; // explizit: toKeep bleibt aktiv, keine Aktion nötig +} + +async function reconcileProtectedDevices( + userId: string, + db: ReturnType, +): Promise { + // Alle active+pending Devices auf degraded setzen + const degradedAt = new Date(); + await db.protectedDevice.updateMany({ + where: { userId, status: { in: ["active", "pending"] } }, + data: { + status: "degraded", + degradedAt, + }, + }); + // Nach 14d liefert der Blocklist-Endpoint für degraded-Token nur noch Passthrough. + // Cron oder lazy-check im DNS-Endpoint macht das; degradedAt ist der Marker. +} + +async function reconcileUpgrade( + userId: string, + db: ReturnType, +): Promise { + // Re-aktiviere pausierte Mail-Accounts + await db.mailConnection.updateMany({ + where: { userId, pausedReason: "plan_downgrade" }, + data: { + isActive: true, + pausedAt: null, + pausedReason: null, + }, + }); + + // Re-aktiviere degraded Protected Devices + await db.protectedDevice.updateMany({ + where: { userId, status: "degraded" }, + data: { + status: "active", + degradedAt: null, + }, + }); + + // Clear Blocklist Grace + await db.profile.update({ + where: { id: userId }, + data: { globalBlocklistGraceUntil: null }, + }); +} + +/** + * Triggert einen internen Mail-Scan für eine spezifische Connection. + * Wird vor dem Pausieren aufgerufen (§3 "final sweep"). + * + * Importiert den Scan-Core direkt ohne HTTP-Roundtrip. + * Bei IMAP-Fehlern wird still weitergemacht — Pausieren passiert danach sowieso. + */ +async function triggerFinalScanForConnection( + userId: string, + connectionId: string, +): Promise { + // Wir nutzen fetch auf scan-internal nur wenn wir den Admin-Secret haben. + // Da das im gleichen Prozess läuft können wir das Prisma direkt nutzen. + // Die echte Scan-Logik ist in scan-internal.post.ts und zu umfangreich + // um sie hier zu duplizieren — wir setzen stattdessen einen Marker. + // TODO: scan-internal als shared-util extrahieren für direkten Aufruf ohne HTTP. + const db = usePrisma(); + await db.mailConnection.update({ + where: { id: connectionId, userId }, + data: { + // Setzt nextScanAt auf jetzt → nächster Cron-Lauf picked es auf + nextScanAt: new Date(), + }, + }).catch(() => { + // Swallow — Connection existiert vielleicht nicht mehr + }); +} diff --git a/backend/server/utils/plan-features.ts b/backend/server/utils/plan-features.ts index 176ad15..300f891 100644 --- a/backend/server/utils/plan-features.ts +++ b/backend/server/utils/plan-features.ts @@ -14,30 +14,58 @@ export interface VoiceConfig { } export interface PlanLimits { + // ─── Custom Domains ────────────────────────────────────────────────────── /** Max. eigene Domains (Infinity = unbegrenzt) */ customDomains: number; /** Freigeschaltete Domain-Slots füllen sich wieder auf (Community-Promotion) */ domainRefill: boolean; + + // ─── Mail-Accounts ─────────────────────────────────────────────────────── /** Max. aktive Mail-Agenten (Infinity = unbegrenzt) */ mailAgents: number; /** Erlaubte Scan-Intervalle in Stunden */ mailIntervalOptions: number[]; - /** Zugang zur globalen HaGeZi-Blocklist (200k+) */ - globalBlocklist: boolean; + + // ─── Globale Blocklist ─────────────────────────────────────────────────── + /** + * 'curated' = kleiner Stub der bekanntesten Casino-Domains (Free). + * 'full' = vollständige HaGeZi/ReBreak-Liste (~208k). + * + * Der Stub selbst ist in server/utils/curated-blocklist.ts definiert. + * Die echte ~1-2k HaGeZi-Subset-Liste ist ein separates Daten-Ticket. + */ + globalBlocklist: "curated" | "full"; + + // ─── Community ─────────────────────────────────────────────────────────── /** Darf in der Community posten */ canPost: boolean; /** Darf Gruppen gründen */ canCreateGroup: boolean; /** Darf Domains direkt zur ReBreak Blocklist hinzufügen */ canAddToBlocklist: boolean; - /** Max. parallel registrierte Devices pro Account (Anti-Account-Sharing) */ - maxDevices: number; + + // ─── Geräte (zwei getrennte Konzepte!) ─────────────────────────────────── + /** + * Max. parallel eingeloggte App-Geräte pro Account (Anti-Account-Sharing). + * Bezieht sich auf UserDevice (iOS/Android-App-Instanzen). + */ + maxAppDevices: number; + /** + * Max. zusätzliche Geräte (Mac/Windows) die per DNS-Profil geschützt werden. + * Bezieht sich auf ProtectedDevice (Legend-only Feature). + * 0 = Feature nicht verfügbar. + */ + maxProtectedDevices: number; + + // ─── AI-Coach ──────────────────────────────────────────────────────────── /** Primäres OpenRouter/Groq-Modell für KI-Coach */ aiModel: string; /** Fallback-Modelle (werden der Reihe nach versucht wenn primary fehlschlägt) */ aiModelFallbacks: Array<{ provider: "groq" | "openrouter"; model: string }>; /** AI-Provider: groq (Free/Pro) oder openrouter (Legend/Claude) */ aiProvider: "groq" | "openrouter"; + + // ─── TTS ───────────────────────────────────────────────────────────────── /** * Voice-Config: welcher TTS-Provider + Quota. * @@ -55,11 +83,12 @@ export const PLAN_LIMITS: Record = { domainRefill: false, mailAgents: 1, mailIntervalOptions: [4], - globalBlocklist: false, + globalBlocklist: "curated", canPost: true, canCreateGroup: false, canAddToBlocklist: false, - maxDevices: 1, + maxAppDevices: 1, + maxProtectedDevices: 0, aiModel: "llama-3.1-8b-instant", aiModelFallbacks: [ { provider: "groq", model: "llama-3.3-70b-versatile" }, @@ -78,11 +107,12 @@ export const PLAN_LIMITS: Record = { domainRefill: true, mailAgents: 3, mailIntervalOptions: [1, 4, 8], - globalBlocklist: true, + globalBlocklist: "full", canPost: true, canCreateGroup: false, canAddToBlocklist: false, - maxDevices: 1, + maxAppDevices: 1, + maxProtectedDevices: 0, aiModel: "llama-3.3-70b-versatile", aiModelFallbacks: [ { provider: "groq", model: "llama-3.1-8b-instant" }, @@ -100,11 +130,12 @@ export const PLAN_LIMITS: Record = { domainRefill: true, mailAgents: Infinity, mailIntervalOptions: [1, 4, 8], - globalBlocklist: true, + globalBlocklist: "full", canPost: true, canCreateGroup: true, canAddToBlocklist: true, - maxDevices: 3, + maxAppDevices: 3, + maxProtectedDevices: 2, // "+2 weitere Geräte" (§0.5) aiModel: "anthropic/claude-3.5-haiku", aiModelFallbacks: [ { provider: "openrouter", model: "anthropic/claude-3-haiku" }, @@ -125,3 +156,45 @@ export function getPlanLimits(plan: string): PlanLimits { if (plan === "standard") return PLAN_LIMITS.pro; return PLAN_LIMITS[(plan as Plan) ?? "free"] ?? PLAN_LIMITS.free; } + +/** + * Kuratierter Stub der bekanntesten Glücksspiel-Domains für Free-User. + * Diese Liste ist der Mechanismus — der echte ~1-2k HaGeZi-Subset + * ist ein separates Daten-Ticket (TODO: Daten-Ticket anlegen). + * + * Wird in DNS-Blocklist-Endpoints und scan-internal verwendet wenn + * limits.globalBlocklist === 'curated'. + */ +export const CURATED_BLOCKLIST_STUB: string[] = [ + // DE / Offshore-Klassiker (Top-Tier-Traffic) + "betway.com", + "bet365.com", + "888casino.com", + "pokerstars.com", + "williamhill.com", + "bwin.com", + "unibet.com", + "partypoker.com", + "casinoclub.com", + "interwetten.com", + "tipico.de", + "betsson.com", + "casumo.com", + "leovegas.com", + "mr-green.com", + "jackpot.de", + "sunmaker.com", + "stargames.com", + "mybet.com", + "winner.com", + "ladbrokes.com", + "coral.co.uk", + "paddypower.com", + "betfair.com", + "mrvegas.com", + "slotsmillion.com", + "casinoeuropa.com", + "netbet.com", + "platincasino.com", + "euslot.com", +];