feat(tier): plan limits Rev.2 + downgrade reconciliation + change-preview (Phase 2 backend)
- 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=<plan>: 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) <noreply@anthropic.com>
This commit is contained in:
parent
51697c3aa4
commit
335945fe2c
@ -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;
|
||||||
@ -61,6 +61,17 @@ model Profile {
|
|||||||
voiceSecondsUsedToday Int @default(0) @map("voice_seconds_used_today")
|
voiceSecondsUsedToday Int @default(0) @map("voice_seconds_used_today")
|
||||||
voiceQuotaResetAt DateTime? @map("voice_quota_reset_at")
|
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) ─────────────────────
|
// ─── Admin-Management (Phase E, Migration 20260509) ─────────────────────
|
||||||
// banned: User wird auf API-Ebene blockiert (kein Login-Block — Supabase
|
// banned: User wird auf API-Ebene blockiert (kein Login-Block — Supabase
|
||||||
// bleibt unberührt). Soft-Delete scrubbt PII statt Hard-Delete (DSGVO).
|
// bleibt unberührt). Soft-Delete scrubbt PII statt Hard-Delete (DSGVO).
|
||||||
@ -475,6 +486,10 @@ model MailConnection {
|
|||||||
useStarttls Boolean @default(false) @map("use_starttls")
|
useStarttls Boolean @default(false) @map("use_starttls")
|
||||||
passwordEncrypted String @map("password_encrypted")
|
passwordEncrypted String @map("password_encrypted")
|
||||||
isActive Boolean @default(true) @map("is_active")
|
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")
|
scanInterval Int @default(24) @map("scan_interval")
|
||||||
lastScannedAt DateTime? @map("last_scanned_at")
|
lastScannedAt DateTime? @map("last_scanned_at")
|
||||||
nextScanAt DateTime? @map("next_scan_at")
|
nextScanAt DateTime? @map("next_scan_at")
|
||||||
@ -723,12 +738,15 @@ model ProtectedDevice {
|
|||||||
platform String
|
platform String
|
||||||
/// User-friendly label, z.B. "MacBook Pro" oder "Olfas iPhone"
|
/// User-friendly label, z.B. "MacBook Pro" oder "Olfas iPhone"
|
||||||
label String
|
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")
|
status String @default("pending")
|
||||||
/// User confirmed install via App (not server-side verified yet — DoH-routing kommt in Phase 2)
|
/// User confirmed install via App (not server-side verified yet — DoH-routing kommt in Phase 2)
|
||||||
installedAt DateTime? @map("installed_at")
|
installedAt DateTime? @map("installed_at")
|
||||||
/// Optional: DoH-server pingt das später (Phase 2, separater Sprint)
|
/// Optional: DoH-server pingt das später (Phase 2, separater Sprint)
|
||||||
lastDnsQueryAt DateTime? @map("last_dns_query_at")
|
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")
|
revokedAt DateTime? @map("revoked_at")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|||||||
@ -1,21 +1,40 @@
|
|||||||
import { getProfile } from "../../db/profile";
|
import { getProfile } from "../../db/profile";
|
||||||
|
import { getPlanLimits } from "../../utils/plan-features";
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const user = await requireUser(event);
|
const user = await requireUser(event);
|
||||||
const dbProfile = await getProfile(user.id);
|
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 {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
username: dbProfile?.username ?? "",
|
username: dbProfile?.username ?? "",
|
||||||
nickname: dbProfile?.nickname ?? null,
|
nickname: dbProfile?.nickname ?? null,
|
||||||
avatar: dbProfile?.avatar ?? null,
|
avatar: dbProfile?.avatar ?? null,
|
||||||
plan: (dbProfile?.plan === "premium"
|
plan,
|
||||||
? "legend"
|
foundingMember: dbProfile?.foundingMember ?? false,
|
||||||
: dbProfile?.plan === "standard"
|
|
||||||
? "pro"
|
|
||||||
: dbProfile?.plan ?? "free") as "free" | "pro" | "legend",
|
|
||||||
streak: dbProfile?.streak ?? 0,
|
streak: dbProfile?.streak ?? 0,
|
||||||
created_at: dbProfile?.createdAt?.toISOString() ?? user.created_at,
|
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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@ -29,7 +29,12 @@ export default defineEventHandler(async (event) => {
|
|||||||
if (activeCount >= limits.customDomains) {
|
if (activeCount >= limits.customDomains) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
message: `Dein Plan erlaubt maximal ${limits.customDomains} eigene Domains`,
|
data: {
|
||||||
|
error: "plan_limit",
|
||||||
|
resource: "custom_domains",
|
||||||
|
current: activeCount,
|
||||||
|
limit: limits.customDomains,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import { usePrisma } from "../../utils/prisma";
|
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;
|
const VALID_PLANS = ["free", "pro", "legend"] as const;
|
||||||
type AppPlan = (typeof VALID_PLANS)[number];
|
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.
|
* DEV/STAGING-ONLY: Setzt den eigenen Plan ohne Admin-Rechte.
|
||||||
* Blocked in Production (appUrl enthält "rebreak.org" aber NICHT "staging").
|
* Blocked in Production (appUrl enthält "rebreak.org" aber NICHT "staging").
|
||||||
*
|
*
|
||||||
* Body: { plan: "free" | "pro" | "legend" }
|
* Body: { plan: "free" | "pro" | "legend", foundingMember?: boolean }
|
||||||
* Response: { success: true, plan: AppPlan }
|
* Response: { success: true, plan: AppPlan, foundingMember: boolean, reconciled: boolean }
|
||||||
*/
|
*/
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const user = await requireUser(event);
|
const user = await requireUser(event);
|
||||||
@ -26,6 +28,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
const body = await readBody(event).catch(() => ({}));
|
const body = await readBody(event).catch(() => ({}));
|
||||||
const plan = body?.plan as string | undefined;
|
const plan = body?.plan as string | undefined;
|
||||||
|
const setFoundingMember = body?.foundingMember as boolean | undefined;
|
||||||
|
|
||||||
if (!plan || !(VALID_PLANS as readonly string[]).includes(plan)) {
|
if (!plan || !(VALID_PLANS as readonly string[]).includes(plan)) {
|
||||||
throw createError({
|
throw createError({
|
||||||
@ -38,10 +41,46 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const db = usePrisma();
|
const db = usePrisma();
|
||||||
await db.profile.update({
|
|
||||||
|
// Aktuellen Plan lesen für Reconciliation
|
||||||
|
const current = await db.profile.findUnique({
|
||||||
where: { id: user.id },
|
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<string, unknown> = { 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,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { randomBytes } from "crypto";
|
import { randomBytes } from "crypto";
|
||||||
import { getProfile } from "../../db/profile";
|
import { getProfile } from "../../db/profile";
|
||||||
|
import { getPlanLimits } from "../../utils/plan-features";
|
||||||
import {
|
import {
|
||||||
countActiveProtectedDevices,
|
countActiveProtectedDevices,
|
||||||
createProtectedDevice,
|
createProtectedDevice,
|
||||||
@ -19,7 +20,10 @@ export default defineEventHandler(async (event) => {
|
|||||||
const user = await requireUser(event);
|
const user = await requireUser(event);
|
||||||
|
|
||||||
const profile = await getProfile(user.id);
|
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({
|
throw createError({
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
data: { error: "LEGEND_REQUIRED" },
|
data: { error: "LEGEND_REQUIRED" },
|
||||||
@ -42,12 +46,17 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
const trimmedLabel = label.trim().slice(0, 100);
|
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);
|
const activeCount = await countActiveProtectedDevices(user.id);
|
||||||
if (activeCount >= 3) {
|
if (activeCount >= limits.maxProtectedDevices) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 409,
|
statusCode: 409,
|
||||||
data: { error: "DEVICE_LIMIT_REACHED", max: 3, current: activeCount },
|
data: {
|
||||||
|
error: "plan_limit",
|
||||||
|
resource: "protected_devices",
|
||||||
|
current: activeCount,
|
||||||
|
limit: limits.maxProtectedDevices,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -23,7 +23,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
...d,
|
...d,
|
||||||
isCurrent: !!currentDeviceId && d.deviceId === currentDeviceId,
|
isCurrent: !!currentDeviceId && d.deviceId === currentDeviceId,
|
||||||
})),
|
})),
|
||||||
max: limits.maxDevices,
|
max: limits.maxAppDevices,
|
||||||
plan: profile?.plan ?? "free",
|
plan: profile?.plan ?? "free",
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@ -42,9 +42,9 @@ export default defineEventHandler(async (event) => {
|
|||||||
platform,
|
platform,
|
||||||
model: model ?? null,
|
model: model ?? null,
|
||||||
name: name ?? 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) {
|
} catch (err: any) {
|
||||||
if (err.code === "DEVICE_LIMIT_REACHED") {
|
if (err.code === "DEVICE_LIMIT_REACHED") {
|
||||||
const devices = await listUserDevices(user.id);
|
const devices = await listUserDevices(user.id);
|
||||||
@ -53,7 +53,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
statusMessage: "device_limit_reached",
|
statusMessage: "device_limit_reached",
|
||||||
data: {
|
data: {
|
||||||
error: "device_limit_reached",
|
error: "device_limit_reached",
|
||||||
max: limits.maxDevices,
|
max: limits.maxAppDevices,
|
||||||
plan: profile?.plan ?? "free",
|
plan: profile?.plan ?? "free",
|
||||||
devices,
|
devices,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -37,7 +37,12 @@ export default defineEventHandler(async (event) => {
|
|||||||
if (count >= limits.mailAgents) {
|
if (count >= limits.mailAgents) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 403,
|
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,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,9 +35,14 @@ export default defineEventHandler(async (event) => {
|
|||||||
if (connections.length === 0) return { scanned: 0, blocked: 0 };
|
if (connections.length === 0) return { scanned: 0, blocked: 0 };
|
||||||
|
|
||||||
// Plan-aware blocklist
|
// 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 profile = await getProfile(userId);
|
||||||
const limits = getPlanLimits(profile?.plan ?? "free");
|
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);
|
await deleteOldMailBlocked(userId);
|
||||||
|
|
||||||
|
|||||||
@ -33,7 +33,11 @@ export default defineEventHandler(async (event) => {
|
|||||||
// Plan-aware: Free users get only custom domains, Pro/Legend get global blocklist
|
// Plan-aware: Free users get only custom domains, Pro/Legend get global blocklist
|
||||||
const profile = await getProfile(user.id);
|
const profile = await getProfile(user.id);
|
||||||
const limits = getPlanLimits(profile?.plan ?? "free");
|
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);
|
await deleteOldMailBlocked(user.id);
|
||||||
|
|
||||||
|
|||||||
363
backend/server/api/plan/change-preview.get.ts
Normal file
363
backend/server/api/plan/change-preview.get.ts
Normal file
@ -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=<free|pro|legend>
|
||||||
|
*
|
||||||
|
* 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<Plan, number> = { free: 0, pro: 1, legend: 2 };
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event): Promise<ChangePreviewResponse> => {
|
||||||
|
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<string, string> = {
|
||||||
|
elevenlabs: "ElevenLabs",
|
||||||
|
cartesia: "Cartesia",
|
||||||
|
google: "Google",
|
||||||
|
openai: "OpenAI",
|
||||||
|
azure: "Azure",
|
||||||
|
};
|
||||||
|
return map[provider] ?? provider;
|
||||||
|
}
|
||||||
@ -1,10 +1,12 @@
|
|||||||
import Stripe from "stripe";
|
import Stripe from "stripe";
|
||||||
import { usePrisma } from "../../utils/prisma";
|
import { usePrisma } from "../../utils/prisma";
|
||||||
|
import { runDowngradeReconciliation } from "../../utils/downgrade-reconciliation";
|
||||||
|
import type { Plan } from "../../utils/plan-features";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/stripe/webhook
|
* POST /api/stripe/webhook
|
||||||
* Stripe Webhook – verarbeitet Subscription-Events.
|
* Stripe Webhook – verarbeitet Subscription-Events.
|
||||||
* Aktualisiert profiles.plan + stripe_* Felder.
|
* Aktualisiert profiles.plan + stripe_* Felder + triggert Downgrade-Reconciliation.
|
||||||
*/
|
*/
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const config = useRuntimeConfig();
|
const config = useRuntimeConfig();
|
||||||
@ -41,17 +43,29 @@ export default defineEventHandler(async (event) => {
|
|||||||
const session = stripeEvent.data.object as Stripe.Checkout.Session;
|
const session = stripeEvent.data.object as Stripe.Checkout.Session;
|
||||||
const userId = session.metadata?.user_id || session.client_reference_id;
|
const userId = session.metadata?.user_id || session.client_reference_id;
|
||||||
const plan = session.metadata?.plan || "legend";
|
const plan = session.metadata?.plan || "legend";
|
||||||
|
const newPlan = (
|
||||||
|
plan === "legend" ? "legend" : plan === "pro" ? "pro" : "free"
|
||||||
|
) as Plan;
|
||||||
|
|
||||||
if (userId) {
|
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({
|
await db.profile.update({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
data: {
|
data: {
|
||||||
plan:
|
plan: newPlan,
|
||||||
plan === "legend" ? "legend" : plan === "pro" ? "pro" : "free",
|
|
||||||
stripeCustomerId: session.customer as string,
|
stripeCustomerId: session.customer as string,
|
||||||
stripeSubId: session.subscription as string,
|
stripeSubId: session.subscription as string,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await runDowngradeReconciliation(userId, fromPlan, newPlan).catch(
|
||||||
|
(err) => console.error("[stripe-webhook] reconciliation error:", err),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -67,15 +81,22 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
if (profile) {
|
if (profile) {
|
||||||
const isActive = ["active", "trialing"].includes(sub.status);
|
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({
|
await db.profile.update({
|
||||||
where: { id: profile.id },
|
where: { id: profile.id },
|
||||||
data: {
|
data: {
|
||||||
plan: isActive ? profile.plan : "free",
|
plan: newPlan,
|
||||||
premiumUntil: sub.current_period_end
|
premiumUntil: sub.current_period_end
|
||||||
? new Date(sub.current_period_end * 1000)
|
? new Date(sub.current_period_end * 1000)
|
||||||
: null,
|
: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await runDowngradeReconciliation(profile.id, fromPlan, newPlan).catch(
|
||||||
|
(err) => console.error("[stripe-webhook] reconciliation error:", err),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -86,14 +107,19 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
const profile = await db.profile.findFirst({
|
const profile = await db.profile.findFirst({
|
||||||
where: { stripeCustomerId: customerId },
|
where: { stripeCustomerId: customerId },
|
||||||
select: { id: true },
|
select: { id: true, plan: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (profile) {
|
if (profile) {
|
||||||
|
const fromPlan = profile.plan as Plan;
|
||||||
await db.profile.update({
|
await db.profile.update({
|
||||||
where: { id: profile.id },
|
where: { id: profile.id },
|
||||||
data: { plan: "free", premiumUntil: null },
|
data: { plan: "free", premiumUntil: null },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await runDowngradeReconciliation(profile.id, fromPlan, "free").catch(
|
||||||
|
(err) => console.error("[stripe-webhook] reconciliation error:", err),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,8 +38,15 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
const limits = getPlanLimits(profile?.plan ?? "free");
|
const limits = getPlanLimits(profile?.plan ?? "free");
|
||||||
|
|
||||||
// Global Domains nur für Pro/Legend
|
// Grace-Period: wenn globalBlocklistGraceUntil noch in der Zukunft liegt,
|
||||||
const global = limits.globalBlocklist ? await getActiveBlocklistDomains() : [];
|
// 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:
|
// Beide Listen ohne Salt hashen — vereinfachte Architektur:
|
||||||
// Server kennt die Klartext-Domains eh (via DB), darum bringt User-Salt
|
// Server kennt die Klartext-Domains eh (via DB), darum bringt User-Salt
|
||||||
|
|||||||
@ -2,12 +2,38 @@ import { usePrisma } from "../utils/prisma";
|
|||||||
|
|
||||||
export async function getMailConnections(userId: string) {
|
export async function getMailConnections(userId: string) {
|
||||||
const db = usePrisma();
|
const db = usePrisma();
|
||||||
|
// isActive=true UND nicht pausiert (pausedAt=null) — pausierte werden vom Cron ausgelassen
|
||||||
return db.mailConnection.findMany({
|
return db.mailConnection.findMany({
|
||||||
where: { userId, isActive: true },
|
where: { userId, isActive: true, pausedAt: null },
|
||||||
orderBy: { createdAt: "asc" },
|
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() {
|
export async function getAllActiveMailUserIds() {
|
||||||
const db = usePrisma();
|
const db = usePrisma();
|
||||||
const rows = await db.mailConnection.findMany({
|
const rows = await db.mailConnection.findMany({
|
||||||
@ -20,7 +46,8 @@ export async function getAllActiveMailUserIds() {
|
|||||||
|
|
||||||
export async function countMailConnections(userId: string) {
|
export async function countMailConnections(userId: string) {
|
||||||
const db = usePrisma();
|
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: {
|
export async function upsertMailConnection(data: {
|
||||||
|
|||||||
@ -6,6 +6,7 @@ export interface ProtectedDeviceRecord {
|
|||||||
label: string;
|
label: string;
|
||||||
status: string;
|
status: string;
|
||||||
installedAt: Date | null;
|
installedAt: Date | null;
|
||||||
|
degradedAt: Date | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -14,6 +15,22 @@ export interface ProtectedDeviceWithToken extends ProtectedDeviceRecord {
|
|||||||
userId: string;
|
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. */
|
/** Alle nicht-revoked Devices eines Users, neueste zuerst. */
|
||||||
export async function listProtectedDevices(
|
export async function listProtectedDevices(
|
||||||
userId: string,
|
userId: string,
|
||||||
@ -22,18 +39,11 @@ export async function listProtectedDevices(
|
|||||||
return db.protectedDevice.findMany({
|
return db.protectedDevice.findMany({
|
||||||
where: { userId, status: { not: "revoked" } },
|
where: { userId, status: { not: "revoked" } },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
select: {
|
select: DEVICE_SELECT,
|
||||||
id: true,
|
|
||||||
platform: true,
|
|
||||||
label: true,
|
|
||||||
status: true,
|
|
||||||
installedAt: true,
|
|
||||||
createdAt: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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(
|
export async function countActiveProtectedDevices(
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
@ -50,16 +60,18 @@ export async function getProtectedDevice(
|
|||||||
const db = usePrisma();
|
const db = usePrisma();
|
||||||
return db.protectedDevice.findUnique({
|
return db.protectedDevice.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
select: {
|
select: DEVICE_SELECT_WITH_TOKEN,
|
||||||
id: true,
|
});
|
||||||
userId: true,
|
}
|
||||||
dnsToken: true,
|
|
||||||
platform: true,
|
/** Lookup by dnsToken — für DoH-Blocklist-Endpoint (Token aus URL). */
|
||||||
label: true,
|
export async function getProtectedDeviceByToken(
|
||||||
status: true,
|
dnsToken: string,
|
||||||
installedAt: true,
|
): Promise<ProtectedDeviceWithToken | null> {
|
||||||
createdAt: true,
|
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,
|
label: opts.label,
|
||||||
status: "pending",
|
status: "pending",
|
||||||
},
|
},
|
||||||
select: {
|
select: DEVICE_SELECT_WITH_TOKEN,
|
||||||
id: true,
|
|
||||||
userId: true,
|
|
||||||
dnsToken: true,
|
|
||||||
platform: true,
|
|
||||||
label: true,
|
|
||||||
status: true,
|
|
||||||
installedAt: true,
|
|
||||||
createdAt: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -109,14 +112,7 @@ export async function confirmProtectedDeviceInstalled(
|
|||||||
status: "active",
|
status: "active",
|
||||||
installedAt: new Date(),
|
installedAt: new Date(),
|
||||||
},
|
},
|
||||||
select: {
|
select: DEVICE_SELECT,
|
||||||
id: true,
|
|
||||||
platform: true,
|
|
||||||
label: true,
|
|
||||||
status: true,
|
|
||||||
installedAt: true,
|
|
||||||
createdAt: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -137,3 +133,31 @@ export async function revokeProtectedDevice(
|
|||||||
});
|
});
|
||||||
return true;
|
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";
|
||||||
|
}
|
||||||
|
|||||||
@ -73,7 +73,7 @@ export async function requireUser(
|
|||||||
userId: user.id,
|
userId: user.id,
|
||||||
deviceId,
|
deviceId,
|
||||||
platform,
|
platform,
|
||||||
maxDevices: limits.maxDevices,
|
maxDevices: limits.maxAppDevices,
|
||||||
});
|
});
|
||||||
return user;
|
return user;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@ -88,7 +88,7 @@ export async function requireUser(
|
|||||||
statusMessage: 'device_limit_reached',
|
statusMessage: 'device_limit_reached',
|
||||||
data: {
|
data: {
|
||||||
error: 'device_limit_reached',
|
error: 'device_limit_reached',
|
||||||
max: limits.maxDevices,
|
max: limits.maxAppDevices,
|
||||||
plan: profile?.plan ?? 'free',
|
plan: profile?.plan ?? 'free',
|
||||||
devices,
|
devices,
|
||||||
},
|
},
|
||||||
|
|||||||
189
backend/server/utils/downgrade-reconciliation.ts
Normal file
189
backend/server/utils/downgrade-reconciliation.ts
Normal file
@ -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<void> {
|
||||||
|
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<typeof usePrisma>,
|
||||||
|
): Promise<void> {
|
||||||
|
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<typeof usePrisma>,
|
||||||
|
): Promise<void> {
|
||||||
|
// 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<typeof usePrisma>,
|
||||||
|
): Promise<void> {
|
||||||
|
// 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<void> {
|
||||||
|
// 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
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -14,30 +14,58 @@ export interface VoiceConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PlanLimits {
|
export interface PlanLimits {
|
||||||
|
// ─── Custom Domains ──────────────────────────────────────────────────────
|
||||||
/** Max. eigene Domains (Infinity = unbegrenzt) */
|
/** Max. eigene Domains (Infinity = unbegrenzt) */
|
||||||
customDomains: number;
|
customDomains: number;
|
||||||
/** Freigeschaltete Domain-Slots füllen sich wieder auf (Community-Promotion) */
|
/** Freigeschaltete Domain-Slots füllen sich wieder auf (Community-Promotion) */
|
||||||
domainRefill: boolean;
|
domainRefill: boolean;
|
||||||
|
|
||||||
|
// ─── Mail-Accounts ───────────────────────────────────────────────────────
|
||||||
/** Max. aktive Mail-Agenten (Infinity = unbegrenzt) */
|
/** Max. aktive Mail-Agenten (Infinity = unbegrenzt) */
|
||||||
mailAgents: number;
|
mailAgents: number;
|
||||||
/** Erlaubte Scan-Intervalle in Stunden */
|
/** Erlaubte Scan-Intervalle in Stunden */
|
||||||
mailIntervalOptions: number[];
|
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 */
|
/** Darf in der Community posten */
|
||||||
canPost: boolean;
|
canPost: boolean;
|
||||||
/** Darf Gruppen gründen */
|
/** Darf Gruppen gründen */
|
||||||
canCreateGroup: boolean;
|
canCreateGroup: boolean;
|
||||||
/** Darf Domains direkt zur ReBreak Blocklist hinzufügen */
|
/** Darf Domains direkt zur ReBreak Blocklist hinzufügen */
|
||||||
canAddToBlocklist: boolean;
|
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 */
|
/** Primäres OpenRouter/Groq-Modell für KI-Coach */
|
||||||
aiModel: string;
|
aiModel: string;
|
||||||
/** Fallback-Modelle (werden der Reihe nach versucht wenn primary fehlschlägt) */
|
/** Fallback-Modelle (werden der Reihe nach versucht wenn primary fehlschlägt) */
|
||||||
aiModelFallbacks: Array<{ provider: "groq" | "openrouter"; model: string }>;
|
aiModelFallbacks: Array<{ provider: "groq" | "openrouter"; model: string }>;
|
||||||
/** AI-Provider: groq (Free/Pro) oder openrouter (Legend/Claude) */
|
/** AI-Provider: groq (Free/Pro) oder openrouter (Legend/Claude) */
|
||||||
aiProvider: "groq" | "openrouter";
|
aiProvider: "groq" | "openrouter";
|
||||||
|
|
||||||
|
// ─── TTS ─────────────────────────────────────────────────────────────────
|
||||||
/**
|
/**
|
||||||
* Voice-Config: welcher TTS-Provider + Quota.
|
* Voice-Config: welcher TTS-Provider + Quota.
|
||||||
*
|
*
|
||||||
@ -55,11 +83,12 @@ export const PLAN_LIMITS: Record<Plan, PlanLimits> = {
|
|||||||
domainRefill: false,
|
domainRefill: false,
|
||||||
mailAgents: 1,
|
mailAgents: 1,
|
||||||
mailIntervalOptions: [4],
|
mailIntervalOptions: [4],
|
||||||
globalBlocklist: false,
|
globalBlocklist: "curated",
|
||||||
canPost: true,
|
canPost: true,
|
||||||
canCreateGroup: false,
|
canCreateGroup: false,
|
||||||
canAddToBlocklist: false,
|
canAddToBlocklist: false,
|
||||||
maxDevices: 1,
|
maxAppDevices: 1,
|
||||||
|
maxProtectedDevices: 0,
|
||||||
aiModel: "llama-3.1-8b-instant",
|
aiModel: "llama-3.1-8b-instant",
|
||||||
aiModelFallbacks: [
|
aiModelFallbacks: [
|
||||||
{ provider: "groq", model: "llama-3.3-70b-versatile" },
|
{ provider: "groq", model: "llama-3.3-70b-versatile" },
|
||||||
@ -78,11 +107,12 @@ export const PLAN_LIMITS: Record<Plan, PlanLimits> = {
|
|||||||
domainRefill: true,
|
domainRefill: true,
|
||||||
mailAgents: 3,
|
mailAgents: 3,
|
||||||
mailIntervalOptions: [1, 4, 8],
|
mailIntervalOptions: [1, 4, 8],
|
||||||
globalBlocklist: true,
|
globalBlocklist: "full",
|
||||||
canPost: true,
|
canPost: true,
|
||||||
canCreateGroup: false,
|
canCreateGroup: false,
|
||||||
canAddToBlocklist: false,
|
canAddToBlocklist: false,
|
||||||
maxDevices: 1,
|
maxAppDevices: 1,
|
||||||
|
maxProtectedDevices: 0,
|
||||||
aiModel: "llama-3.3-70b-versatile",
|
aiModel: "llama-3.3-70b-versatile",
|
||||||
aiModelFallbacks: [
|
aiModelFallbacks: [
|
||||||
{ provider: "groq", model: "llama-3.1-8b-instant" },
|
{ provider: "groq", model: "llama-3.1-8b-instant" },
|
||||||
@ -100,11 +130,12 @@ export const PLAN_LIMITS: Record<Plan, PlanLimits> = {
|
|||||||
domainRefill: true,
|
domainRefill: true,
|
||||||
mailAgents: Infinity,
|
mailAgents: Infinity,
|
||||||
mailIntervalOptions: [1, 4, 8],
|
mailIntervalOptions: [1, 4, 8],
|
||||||
globalBlocklist: true,
|
globalBlocklist: "full",
|
||||||
canPost: true,
|
canPost: true,
|
||||||
canCreateGroup: true,
|
canCreateGroup: true,
|
||||||
canAddToBlocklist: true,
|
canAddToBlocklist: true,
|
||||||
maxDevices: 3,
|
maxAppDevices: 3,
|
||||||
|
maxProtectedDevices: 2, // "+2 weitere Geräte" (§0.5)
|
||||||
aiModel: "anthropic/claude-3.5-haiku",
|
aiModel: "anthropic/claude-3.5-haiku",
|
||||||
aiModelFallbacks: [
|
aiModelFallbacks: [
|
||||||
{ provider: "openrouter", model: "anthropic/claude-3-haiku" },
|
{ provider: "openrouter", model: "anthropic/claude-3-haiku" },
|
||||||
@ -125,3 +156,45 @@ export function getPlanLimits(plan: string): PlanLimits {
|
|||||||
if (plan === "standard") return PLAN_LIMITS.pro;
|
if (plan === "standard") return PLAN_LIMITS.pro;
|
||||||
return PLAN_LIMITS[(plan as Plan) ?? "free"] ?? PLAN_LIMITS.free;
|
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",
|
||||||
|
];
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user