plan-features.customDomains is now { web, mail } per plan instead of a
single number. Free 5+5, Pro 5+5, Legend 10+10 — the user explicitly
chose separate pools so users don't have to trade a website slot for a
mail-pattern slot or vice versa.
- countActiveCustomDomainsSplit(userId) groupBy type → { web, mail }
(mail aggregates mail_domain + mail_display_name). Old single-count
function stays as a deprecated alias for any caller still on it.
- POST /api/custom-domains: body-compat accepts both { pattern, kind }
(current frontend) and { domain, type } (legacy / direct). kind='mail'
is split into mail_domain vs mail_display_name server-side based on
whether the pattern looks like a domain. Slot check is per-bucket;
errors are WEB_LIMIT_REACHED / MAIL_LIMIT_REACHED so the UI can show
the right limit-reached message per tab.
- GET /api/custom-domains: response shape extended to
{ items, counts: { web, mail }, limits: { web, mail } } so the
frontend can drive the per-tab counter without client-side estimation.
- POST /api/custom-domains/:id/submit: hard-blocks mail_display_name
with 400 DISPLAY_NAME_NOT_SUBMITTABLE. Display-name submission to the
global blocklist is deferred to v1.1 — would require a schema split
on BlocklistDomain that's risky pre-TestFlight. mail_domain still
flows through the community-vote pipeline like web entries.
- auth/me.get.ts, plan/change-preview.get.ts, coach/message.post.ts
updated for the new shape (Lyra prompts untouched, only template
variables split web vs mail counts).
24 vitest cases in backend/tests/custom-domains/plan-limits.test.ts
cover the new shape, body compat, bucket logic, and the submit guard;
216/216 total backend tests pass.
371 lines
14 KiB
TypeScript
371 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 ────────────────────────────────────────────────────────
|
|
// Compare total (web + mail) to detect any bucket change
|
|
const fromTotalDomains = fromLimits.customDomains.web + fromLimits.customDomains.mail;
|
|
const toTotalDomains = toLimits.customDomains.web + toLimits.customDomains.mail;
|
|
if (fromTotalDomains !== toTotalDomains) {
|
|
if (direction === "downgrade") {
|
|
const newLimitWeb = toLimits.customDomains.web;
|
|
const newLimitMail = toLimits.customDomains.mail;
|
|
const newLimitTotal = newLimitWeb + newLimitMail;
|
|
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} (${newLimitWeb} Web + ${newLimitMail} Mail). ` +
|
|
`Alle bleiben aktiv — du kannst erst wieder welche hinzufügen wenn du unter dem jeweiligen Limit bist.`
|
|
: `Du hast ${activeDomainCount} von ${newLimitTotal} möglichen Domains (${newLimitWeb} Web + ${newLimitMail} Mail) — kein Überlauf.`,
|
|
});
|
|
} else {
|
|
const web = toLimits.customDomains.web;
|
|
const mail = toLimits.customDomains.mail;
|
|
gains.push(
|
|
`Bis zu ${web} Web-Domains + ${mail} Mail-Patterns${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;
|
|
}
|