rebreak-monorepo/backend/server/api/plan/change-preview.get.ts
chahinebrini 704958320b refactor(domains): gemeinsamer 10/20-Slot-Pool, Free-Tier entfernt
Custom-Domain-Slots sind jetzt EIN gemeinsamer Pool für web + mail
(Pro 10 / Legend 20) statt getrennter web/mail-Buckets. Free-Tier ist
entfallen — PLAN_LIMITS hat nur noch pro + legend, getPlanLimits
defaultet auf pro.

Backend:
- plan-features: customDomains ist eine Zahl (CustomDomainLimits weg)
- index.post: Slot-Check gegen Gesamt-Count, Fehler einheitlich LIMIT_REACHED
- index.get: liefert { items, count, limit }
- change-preview + coach/message an die neue Form angepasst

Frontend:
- useCustomDomains: count/limit (Zahlen) statt countsByType/limits
- AddDomainSheet: ein generischer Limit-Hinweis (error_limit_reached)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 18:40:28 +02:00

367 lines
14 KiB
TypeScript

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 ────────────────────────────────────────────────────────
// Gemeinsamer Pool für web + mail (Pro 10 / Legend 20).
const fromTotalDomains = fromLimits.customDomains;
const toTotalDomains = toLimits.customDomains;
if (fromTotalDomains !== toTotalDomains) {
if (direction === "downgrade") {
const newLimitTotal = toLimits.customDomains;
const overBy = Math.max(0, activeDomainCount - newLimitTotal);
changes.push({
resource: "custom_domains",
current: activeDomainCount,
newLimit: newLimitTotal,
overBy,
action: "keep", // grandfathered — alle bleiben aktiv
detail:
overBy > 0
? `Du hast ${activeDomainCount} eigene Domains, ${to}-Plan erlaubt ${newLimitTotal}. ` +
`Alle bleiben aktiv — du kannst erst wieder welche hinzufügen wenn du unter dem Limit bist.`
: `Du hast ${activeDomainCount} von ${newLimitTotal} möglichen Domains — kein Überlauf.`,
});
} else {
gains.push(
`Bis zu ${toLimits.customDomains} eigene Domains, Web oder Mail${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;
}