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>
This commit is contained in:
chahinebrini 2026-05-22 18:40:28 +02:00
parent 7cc30db020
commit 704958320b
11 changed files with 93 additions and 174 deletions

View File

@ -43,8 +43,8 @@ export default function BlockerScreen() {
const { const {
domains, domains,
tier, tier,
countsByType, count: domainCount,
limits, limit: domainLimit,
addDomain, addDomain,
submitDomain, submitDomain,
refresh: refreshDomains, refresh: refreshDomains,
@ -324,8 +324,8 @@ export default function BlockerScreen() {
<MyFiltersList <MyFiltersList
domains={domains} domains={domains}
tier={tier} tier={tier}
totalCount={countsByType.web + countsByType.mail} totalCount={domainCount}
totalLimit={limits.web + limits.mail} totalLimit={domainLimit}
globalBlocklistCount={state.blocklistCount} globalBlocklistCount={state.blocklistCount}
onAddPress={() => setAddSheetOpen(true)} onAddPress={() => setAddSheetOpen(true)}
onSubmitDomain={submitDomain} onSubmitDomain={submitDomain}

View File

@ -113,16 +113,8 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
return; return;
} }
const raw = (result.error ?? '').toLowerCase(); const raw = (result.error ?? '').toLowerCase();
if (raw.includes('web_limit_reached')) { if (raw.includes('limit_reached')) {
setError(t('blocker.error_web_limit_reached')); setError(t('blocker.error_limit_reached'));
} else if (raw.includes('mail_limit_reached')) {
setError(t('blocker.error_mail_limit_reached'));
} else if (raw === 'limit_reached' || raw.includes('limit_reached')) {
setError(
kind === 'mail'
? t('blocker.error_mail_limit_reached')
: t('blocker.error_web_limit_reached'),
);
} else if (raw.includes('invalid_mail_domain') || raw.includes('display_name_not_supported')) { } else if (raw.includes('invalid_mail_domain') || raw.includes('display_name_not_supported')) {
setError(t('blocker.error_invalid_mail')); setError(t('blocker.error_invalid_mail'));
} else if (raw.includes('invalid_domain') || raw.includes('invalid_pattern')) { } else if (raw.includes('invalid_domain') || raw.includes('invalid_pattern')) {

View File

@ -38,16 +38,17 @@ export type AddDomainResult = {
export type Tier = { export type Tier = {
plan: Plan; plan: Plan;
domainLimit: number; // free=5, pro=5, legend=10 domainLimit: number; // pro=10, legend=20 (web + mail gemeinsam)
refillEnabled: boolean; // free=false, pro/legend=true refillEnabled: boolean; // pro/legend=true
globalBlocklist: boolean; // free=false, pro/legend=true globalBlocklist: boolean; // pro/legend=true
canSubmit: boolean; // free=false, pro/legend=true canSubmit: boolean; // pro/legend=true
usedSlots: number; // active+submitted (NICHT approved/rejected) usedSlots: number; // active+submitted (NICHT approved/rejected)
atLimit: boolean; atLimit: boolean;
}; };
function deriveTier(plan: Plan, domains: CustomDomain[]): Tier { function deriveTier(plan: Plan, domains: CustomDomain[]): Tier {
const limit = plan === 'legend' ? 10 : 5; // Slots: EIN gemeinsamer Pool für web + mail. Free-Tier ist entfallen.
const limit = plan === 'legend' ? 20 : 10;
const refill = plan !== 'free'; const refill = plan !== 'free';
const usedSlots = domains.filter((d) => d.status === 'active' || d.status === 'submitted').length; const usedSlots = domains.filter((d) => d.status === 'active' || d.status === 'submitted').length;
return { return {
@ -61,21 +62,13 @@ function deriveTier(plan: Plan, domains: CustomDomain[]): Tier {
}; };
} }
export type CountsByType = {
web: number;
mail: number;
};
export type LimitsByType = {
web: number;
mail: number;
};
export type UseCustomDomainsReturn = { export type UseCustomDomainsReturn = {
domains: CustomDomain[]; domains: CustomDomain[];
tier: Tier; tier: Tier;
countsByType: CountsByType; /** Belegte Slots (active + submitted) — web + mail gemeinsamer Pool. */
limits: LimitsByType; count: number;
/** Slot-Limit gesamt (Pro 10 / Legend 20). */
limit: number;
loading: boolean; loading: boolean;
error: string | null; error: string | null;
refresh: () => Promise<void>; refresh: () => Promise<void>;
@ -113,43 +106,42 @@ export function isValidDomain(input: string): boolean {
/** /**
* Custom-Domain CRUD gegen `/api/custom-domains/*` mit Tier-aware Limits. * Custom-Domain CRUD gegen `/api/custom-domains/*` mit Tier-aware Limits.
* *
* Tier-Logik (Single-Source-of-Truth: User.plan): * Slot-Modell (Single-Source-of-Truth: User.plan):
* Free 5 Slots, kein Refill, keine Submit * Pro 10 Slots, Refill bei approved/rejected, Submit erlaubt
* Pro 5 Slots, Refill bei approved/rejected, Submit erlaubt * Legend 20 Slots, Refill, Submit
* Legend 10 Slots, Refill, Submit * web + mail teilen sich EINEN gemeinsamen Slot-Pool. Free-Tier ist entfallen.
*/ */
export function useCustomDomains(plan: Plan): UseCustomDomainsReturn { export function useCustomDomains(plan: Plan): UseCustomDomainsReturn {
const [domains, setDomains] = useState<CustomDomain[]>([]); const [domains, setDomains] = useState<CustomDomain[]>([]);
const [apiCounts, setApiCounts] = useState<CountsByType | null>(null); const [apiCount, setApiCount] = useState<number | null>(null);
const [apiLimits, setApiLimits] = useState<LimitsByType | null>(null); const [apiLimit, setApiLimit] = useState<number | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const fetchDomains = useCallback(async () => { const fetchDomains = useCallback(async () => {
try { try {
// Backend `GET /api/custom-domains` returns // Backend `GET /api/custom-domains` returns
// { items: CustomDomain[], counts: { web, mail }, limits: { web, mail } } // { items: CustomDomain[], count: number, limit: number }
// since the slot-pool split (commit f2b81ee). Legacy fall-throughs cover // (count/limit = gemeinsamer web+mail-Slot-Pool). Array-Fallback deckt
// an older shape (bare array or { domains }) in case a cached client // eine ältere Response-Form ab, falls ein gecachter Client das hier
// ever hits this code path before the deploy lands. // trifft bevor das Deploy landet.
const res = await apiFetch< const res = await apiFetch<
| CustomDomain[] | CustomDomain[]
| { domains?: CustomDomain[] } | { items?: CustomDomain[]; domains?: CustomDomain[]; count?: number; limit?: number }
| { items?: CustomDomain[]; counts?: CountsByType; limits?: LimitsByType }
>('/api/custom-domains'); >('/api/custom-domains');
let arr: CustomDomain[] = []; let arr: CustomDomain[] = [];
let counts: CountsByType | null = null; let count: number | null = null;
let limits: LimitsByType | null = null; let limit: number | null = null;
if (Array.isArray(res)) { if (Array.isArray(res)) {
arr = res; arr = res;
} else if (res) { } else if (res) {
arr = (res as any).items ?? (res as any).domains ?? []; arr = (res as any).items ?? (res as any).domains ?? [];
counts = (res as any).counts ?? null; count = typeof (res as any).count === 'number' ? (res as any).count : null;
limits = (res as any).limits ?? null; limit = typeof (res as any).limit === 'number' ? (res as any).limit : null;
} }
setDomains(arr); setDomains(arr);
setApiCounts(counts); setApiCount(count);
setApiLimits(limits); setApiLimit(limit);
setError(null); setError(null);
} catch (e: any) { } catch (e: any) {
console.error('[useCustomDomains] fetch failed:', e?.message ?? e); console.error('[useCustomDomains] fetch failed:', e?.message ?? e);
@ -172,20 +164,12 @@ export function useCustomDomains(plan: Plan): UseCustomDomainsReturn {
const resolvedKind: 'web' | 'mail' = kind ?? (input.includes('@') ? 'mail' : 'web'); const resolvedKind: 'web' | 'mail' = kind ?? (input.includes('@') ? 'mail' : 'web');
if (resolvedKind === 'web' && !isValidDomain(input)) return { ok: false, error: 'invalid_domain' }; if (resolvedKind === 'web' && !isValidDomain(input)) return { ok: false, error: 'invalid_domain' };
if (resolvedKind === 'mail' && !input.trim()) return { ok: false, error: 'invalid_pattern' }; if (resolvedKind === 'mail' && !input.trim()) return { ok: false, error: 'invalid_pattern' };
// Per-Bucket-Limit-Check via Backend-counts/limits (Single Source of Truth). // Slot-Limit-Vorabcheck gegen den Backend-count/limit (Single Source of
// Wenn API noch keine counts/limits geliefert hat (Legacy-Response) → skip, // Truth — EIN gemeinsamer Pool). Wenn die API noch keine count/limit
// Backend rejected dann mit WEB_LIMIT_REACHED / MAIL_LIMIT_REACHED. // geliefert hat → skip, das Backend rejected dann mit LIMIT_REACHED.
// Entfällt bei addToVip: 'approved'-Einträge belegen keinen Slot. // Entfällt bei addToVip: 'approved'-Einträge belegen keinen Slot.
if (!opts?.addToVip && apiCounts && apiLimits) { if (!opts?.addToVip && apiCount != null && apiLimit != null && apiCount >= apiLimit) {
const bucket = resolvedKind; return { ok: false, error: 'limit_reached' };
const used = apiCounts[bucket] ?? 0;
const cap = apiLimits[bucket] ?? Infinity;
if (used >= cap) {
return {
ok: false,
error: bucket === 'mail' ? 'mail_limit_reached' : 'web_limit_reached',
};
}
} }
const pattern = resolvedKind === 'web' ? normalizeDomain(input) : input.trim(); const pattern = resolvedKind === 'web' ? normalizeDomain(input) : input.trim();
const body: Record<string, string | boolean> = { pattern }; const body: Record<string, string | boolean> = { pattern };
@ -207,7 +191,7 @@ export function useCustomDomains(plan: Plan): UseCustomDomainsReturn {
return { ok: false, error: e?.message ?? 'add_failed' }; return { ok: false, error: e?.message ?? 'add_failed' };
} }
}, },
[apiCounts, apiLimits, fetchDomains], [apiCount, apiLimit, fetchDomains],
); );
const submitDomain = useCallback( const submitDomain = useCallback(
@ -240,28 +224,18 @@ export function useCustomDomains(plan: Plan): UseCustomDomainsReturn {
const tier = deriveTier(plan, domains); const tier = deriveTier(plan, domains);
// Prefer API-driven counts/limits when the backend returned the new shape; // API-Werte bevorzugen (Single Source of Truth); lokale Ableitung als
// fall back to local derivation so the UI works during a stale-bundle moment. // Fallback, damit die UI auch bei einem stale-bundle-Moment funktioniert.
const countsByType: CountsByType = const count: number =
apiCounts ?? { apiCount ??
web: domains.filter( domains.filter((d) => d.status === 'active' || d.status === 'submitted').length;
(d) => d.status !== 'approved' && (d.type === 'web' || !d.type), const limit: number = apiLimit ?? (plan === 'legend' ? 20 : 10);
).length,
mail: domains.filter(
(d) => d.status !== 'approved' && d.type === 'mail_domain',
).length,
};
const limits: LimitsByType =
apiLimits ?? {
web: plan === 'legend' ? 10 : 5,
mail: plan === 'legend' ? 10 : 5,
};
return { return {
domains, domains,
tier, tier,
countsByType, count,
limits, limit,
loading, loading,
error, error,
refresh: fetchDomains, refresh: fetchDomains,

View File

@ -376,6 +376,7 @@
"count_label": "%{count}/%{max}", "count_label": "%{count}/%{max}",
"error_web_limit_reached": "Du hast alle Domain-Slots aufgebraucht. Entferne eine Domain oder upgrade auf Pro/Legend.", "error_web_limit_reached": "Du hast alle Domain-Slots aufgebraucht. Entferne eine Domain oder upgrade auf Pro/Legend.",
"error_mail_limit_reached": "Du hast alle Mail-Slots aufgebraucht. Entferne ein Mail-Pattern oder upgrade auf Pro/Legend.", "error_mail_limit_reached": "Du hast alle Mail-Slots aufgebraucht. Entferne ein Mail-Pattern oder upgrade auf Pro/Legend.",
"error_limit_reached": "Alle Domain-Slots belegt. Reiche eine Domain zur Freigabe ein — sobald sie aufgenommen ist, wird ein Slot frei.",
"error_invalid_mail": "Bitte eine vollständige Mail-Adresse oder Mail-Domain eingeben (z.B. info@only4-subscribers.com).", "error_invalid_mail": "Bitte eine vollständige Mail-Adresse oder Mail-Domain eingeben (z.B. info@only4-subscribers.com).",
"error_invalid_input": "Bitte eine gültige Domain oder Mail-Adresse eingeben.", "error_invalid_input": "Bitte eine gültige Domain oder Mail-Adresse eingeben.",
"error_duplicate": "Diesen Eintrag hast du schon — er ist bereits in deiner Filter-Liste.", "error_duplicate": "Diesen Eintrag hast du schon — er ist bereits in deiner Filter-Liste.",

View File

@ -376,6 +376,7 @@
"count_label": "%{count}/%{max}", "count_label": "%{count}/%{max}",
"error_web_limit_reached": "You've used all your domain slots. Remove a domain or upgrade to Pro/Legend.", "error_web_limit_reached": "You've used all your domain slots. Remove a domain or upgrade to Pro/Legend.",
"error_mail_limit_reached": "You've used all your email slots. Remove an email pattern or upgrade to Pro/Legend.", "error_mail_limit_reached": "You've used all your email slots. Remove an email pattern or upgrade to Pro/Legend.",
"error_limit_reached": "All domain slots are full. Submit a domain for review — a slot frees up once it's accepted.",
"error_invalid_mail": "Please enter a full email address or mail domain (e.g. info@only4-subscribers.com).", "error_invalid_mail": "Please enter a full email address or mail domain (e.g. info@only4-subscribers.com).",
"error_invalid_input": "Please enter a valid domain or email address.", "error_invalid_input": "Please enter a valid domain or email address.",
"error_duplicate": "You've already added this entry — it's in your filter list.", "error_duplicate": "You've already added this entry — it's in your filter list.",

View File

@ -253,7 +253,6 @@ import { stripMarkdown } from "../../utils/strip-markdown";
* Preise bleiben hier hardcoded gehören eher zur Billing-Domain. * Preise bleiben hier hardcoded gehören eher zur Billing-Domain.
*/ */
function generatePlanDetails(): string { function generatePlanDetails(): string {
const free = PLAN_LIMITS.free;
const pro = PLAN_LIMITS.pro; const pro = PLAN_LIMITS.pro;
const legend = PLAN_LIMITS.legend; const legend = PLAN_LIMITS.legend;
const fmtCount = (n: number) => (n === Infinity ? "Unbegrenzt" : String(n)); const fmtCount = (n: number) => (n === Infinity ? "Unbegrenzt" : String(n));
@ -262,25 +261,18 @@ function generatePlanDetails(): string {
? "(rückfüllbar Slot wird wieder frei wenn die Domain global aufgenommen ODER von der Community abgelehnt wurde)" ? "(rückfüllbar Slot wird wieder frei wenn die Domain global aufgenommen ODER von der Community abgelehnt wurde)"
: "(NICHT rückfüllbar einmal belegt, bleibt für immer belegt)"; : "(NICHT rückfüllbar einmal belegt, bleibt für immer belegt)";
return `Free (0 €): return `Pro (3,99 € / Monat oder 29 € / Jahr spare 19 %):
- Gambling-Blocker mit ${free.customDomains.web} eigenen Web-Domains + ${free.customDomains.mail} Mail-Patterns ${refillNote(free.domainRefill)} - Gambling-Blocker mit Zugang zur vollständigen 208.000+ globalen Blocklist (Community-gepflegt)
- Free kann Custom Domains NICHT zur Community-Abstimmung einreichen das ist Pro/Legend exklusiv - ${pro.customDomains} eigene Domains, frei aufteilbar auf Web + Mail ${refillNote(pro.domainRefill)}
- ${fmtCount(free.mailAgents)} Mail-Konto, Scan alle ${free.mailIntervalOptions[0]}h - Bis zu ${fmtCount(pro.mailAgents)} Mail-Konten, Scan-Intervall wählbar (${pro.mailIntervalOptions.join("h / ")}h)
- Streak-Tracker, SOS-Hilfe & Spiele-Sammlung, Atemübung - Streak-Tracker, SOS-Hilfe & Spiele-Sammlung, Atemübung
- Community (lesen, posten, voten) - Community (lesen, posten, voten)
- KI-Coach (du, Lyra Basismodell) - KI-Coach (du, Lyra starkes 70B-Modell)
Pro (3,99 / Monat oder 29 / Jahr spare 19 %):
- Alles aus Free PLUS:
- Zugang zur vollständigen 208.000+ globalen Blocklist (Community-gepflegt)
- ${pro.customDomains.web} Web-Domains + ${pro.customDomains.mail} Mail-Patterns ${refillNote(pro.domainRefill)}
- Bis zu ${fmtCount(pro.mailAgents)} Mail-Konten, Scan-Intervall wählbar (${pro.mailIntervalOptions.join("h / ")}h)
- Stärkeres KI-Modell (du, Lyra wirst zu einem 70B-Modell)
- Kann Custom Domains zur Community-Abstimmung einreichen - Kann Custom Domains zur Community-Abstimmung einreichen
Legend (7,99 / Monat oder 59 / Jahr spare 38 %): Legend (7,99 / Monat oder 59 / Jahr spare 38 %):
- Alles aus Pro PLUS: - Alles aus Pro PLUS:
- ${legend.customDomains.web} Web-Domains + ${legend.customDomains.mail} Mail-Patterns ${refillNote(legend.domainRefill)} - ${legend.customDomains} eigene Domains, frei aufteilbar auf Web + Mail ${refillNote(legend.domainRefill)}
- MULTI-DEVICE-SCHUTZ: App auf bis zu 3 WEITEREN Geräten gleichzeitig Familie, Partner, Eltern können mitgeschützt werden ohne extra zu zahlen. Real-life-relevant: viele Betroffene haben mehrere Geräte (iPhone + iPad + alter Laptop) - MULTI-DEVICE-SCHUTZ: App auf bis zu 3 WEITEREN Geräten gleichzeitig Familie, Partner, Eltern können mitgeschützt werden ohne extra zu zahlen. Real-life-relevant: viele Betroffene haben mehrere Geräte (iPhone + iPad + alter Laptop)
- MAIL-DAEMON (echter technischer Durchbruch Alleinstellungsmerkmal!): ${fmtCount(legend.mailAgents)} Mail-Konten mit Echtzeit-IMAP-IDLE-Überwachung. Casino-Mails werden in Sekunden permanent gelöscht sie tauchen nicht mal im Papierkorb auf. Der User sieht nichts. Kein "Sie haben gewonnen!"-Trigger erreicht je das Postfach. Keine andere App im Markt kann das. - MAIL-DAEMON (echter technischer Durchbruch Alleinstellungsmerkmal!): ${fmtCount(legend.mailAgents)} Mail-Konten mit Echtzeit-IMAP-IDLE-Überwachung. Casino-Mails werden in Sekunden permanent gelöscht sie tauchen nicht mal im Papierkorb auf. Der User sieht nichts. Kein "Sie haben gewonnen!"-Trigger erreicht je das Postfach. Keine andere App im Markt kann das.
- Privilegierte Domain-Einreichung: umgeht Community-Vote komplett. Domains werden direkt + priorisiert vom ReBreak-Admin geprüft (schneller als die 24h-Standard-Prüfung der Pro-Submissions). Vertrauensvorteil als Legend. - Privilegierte Domain-Einreichung: umgeht Community-Vote komplett. Domains werden direkt + priorisiert vom ReBreak-Admin geprüft (schneller als die 24h-Standard-Prüfung der Pro-Submissions). Vertrauensvorteil als Legend.

View File

@ -1,21 +1,18 @@
import { getUserCustomDomains, countActiveCustomDomainsSplit } from "../../db/domains"; import { getUserCustomDomains, countActiveCustomDomains } from "../../db/domains";
import { getProfile } from "../../db/profile"; import { getProfile } from "../../db/profile";
import { getPlanLimits } from "../../utils/plan-features"; 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 [items, split, profile] = await Promise.all([ const [items, count, profile] = await Promise.all([
getUserCustomDomains(user.id), getUserCustomDomains(user.id),
countActiveCustomDomainsSplit(user.id), countActiveCustomDomains(user.id),
getProfile(user.id), getProfile(user.id),
]); ]);
const limits = getPlanLimits(profile?.plan ?? "free"); // Slot-Pool: web + mail gemeinsam (Pro 10 / Legend 20).
const limit = getPlanLimits(profile?.plan ?? "pro").customDomains;
return { return { items, count, limit };
items,
counts: split,
limits: limits.customDomains,
};
}); });

View File

@ -1,7 +1,7 @@
import { awardPoints } from "../../utils/scoring"; import { awardPoints } from "../../utils/scoring";
import { import {
addUserCustomDomain, addUserCustomDomain,
countActiveCustomDomainsSplit, countActiveCustomDomains,
CUSTOM_DOMAIN_TYPES, CUSTOM_DOMAIN_TYPES,
type CustomDomainType, type CustomDomainType,
} from "../../db/domains"; } from "../../db/domains";
@ -221,27 +221,22 @@ export default defineEventHandler(async (event) => {
// !inGlobal → normaler Add unten // !inGlobal → normaler Add unten
} }
// Per-type Slot-Limit prüfen — entfällt für webAddAsApproved (approved // Slot-Limit prüfen — EIN gemeinsamer Pool für web + mail (Pro 10 / Legend
// belegt keinen Slot). // 20). Entfällt für webAddAsApproved (approved belegt keinen Slot).
const bucket: "web" | "mail" = type === "web" ? "web" : "mail";
if (!webAddAsApproved) { if (!webAddAsApproved) {
const profile = await getProfile(user.id); const profile = await getProfile(user.id);
const limits = getPlanLimits(profile?.plan ?? "free"); const limit = getPlanLimits(profile?.plan ?? "pro").customDomains;
const bucketLimit = limits.customDomains[bucket];
if (bucketLimit !== Infinity) { if (limit !== Infinity) {
const split = await countActiveCustomDomainsSplit(user.id); const currentCount = await countActiveCustomDomains(user.id);
const currentCount = split[bucket]; if (currentCount >= limit) {
if (currentCount >= bucketLimit) {
const errorCode = bucket === "web" ? "WEB_LIMIT_REACHED" : "MAIL_LIMIT_REACHED";
throw createError({ throw createError({
statusCode: 403, statusCode: 403,
data: { data: {
error: errorCode, error: "LIMIT_REACHED",
resource: "custom_domains", resource: "custom_domains",
bucket,
current: currentCount, current: currentCount,
limit: bucketLimit, limit,
}, },
}); });
} }

View File

@ -144,14 +144,12 @@ export default defineEventHandler(async (event): Promise<ChangePreviewResponse>
} }
// ── Custom Domains ──────────────────────────────────────────────────────── // ── Custom Domains ────────────────────────────────────────────────────────
// Compare total (web + mail) to detect any bucket change // Gemeinsamer Pool für web + mail (Pro 10 / Legend 20).
const fromTotalDomains = fromLimits.customDomains.web + fromLimits.customDomains.mail; const fromTotalDomains = fromLimits.customDomains;
const toTotalDomains = toLimits.customDomains.web + toLimits.customDomains.mail; const toTotalDomains = toLimits.customDomains;
if (fromTotalDomains !== toTotalDomains) { if (fromTotalDomains !== toTotalDomains) {
if (direction === "downgrade") { if (direction === "downgrade") {
const newLimitWeb = toLimits.customDomains.web; const newLimitTotal = toLimits.customDomains;
const newLimitMail = toLimits.customDomains.mail;
const newLimitTotal = newLimitWeb + newLimitMail;
const overBy = Math.max(0, activeDomainCount - newLimitTotal); const overBy = Math.max(0, activeDomainCount - newLimitTotal);
changes.push({ changes.push({
resource: "custom_domains", resource: "custom_domains",
@ -161,15 +159,13 @@ export default defineEventHandler(async (event): Promise<ChangePreviewResponse>
action: "keep", // grandfathered — alle bleiben aktiv action: "keep", // grandfathered — alle bleiben aktiv
detail: detail:
overBy > 0 overBy > 0
? `Du hast ${activeDomainCount} eigene Domains, ${to}-Plan erlaubt ${newLimitTotal} (${newLimitWeb} Web + ${newLimitMail} Mail). ` + ? `Du hast ${activeDomainCount} eigene Domains, ${to}-Plan erlaubt ${newLimitTotal}. ` +
`Alle bleiben aktiv — du kannst erst wieder welche hinzufügen wenn du unter dem jeweiligen Limit bist.` `Alle bleiben aktiv — du kannst erst wieder welche hinzufügen wenn du unter dem Limit bist.`
: `Du hast ${activeDomainCount} von ${newLimitTotal} möglichen Domains (${newLimitWeb} Web + ${newLimitMail} Mail) — kein Überlauf.`, : `Du hast ${activeDomainCount} von ${newLimitTotal} möglichen Domains — kein Überlauf.`,
}); });
} else { } else {
const web = toLimits.customDomains.web;
const mail = toLimits.customDomains.mail;
gains.push( 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)" : ""}`, `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)" : ""}`,
); );
} }
} }

View File

@ -74,11 +74,12 @@ export async function getUserCustomDomains(userId: string) {
} }
/** /**
* Counts domains that occupy a slot (active + submitted). * Zählt die Domains, die einen Slot belegen (active + submitted).
* approved slot freed (domain joined global list) * approved Slot frei (Domain in globale Liste aufgenommen)
* rejected slot freed (user can re-submit or delete) * rejected Slot frei (User kann neu einreichen)
* *
* @deprecated Use countActiveCustomDomainsSplit for per-type quota checks. * Single Source of Truth für das Slot-Limit web + mail teilen sich EINEN
* gemeinsamen Pool (Pro 10 / Legend 20).
*/ */
export async function countActiveCustomDomains(userId: string) { export async function countActiveCustomDomains(userId: string) {
const db = usePrisma(); const db = usePrisma();

View File

@ -13,17 +13,10 @@ export interface VoiceConfig {
dailyQuotaSeconds: number; dailyQuotaSeconds: number;
} }
export interface CustomDomainLimits {
/** Max. Web-Domain-Slots (Infinity = unbegrenzt) */
web: number;
/** Max. Mail-Slots — kombiniert für mail_domain + mail_display_name */
mail: number;
}
export interface PlanLimits { export interface PlanLimits {
// ─── Custom Domains ────────────────────────────────────────────────────── // ─── Custom Domains ──────────────────────────────────────────────────────
/** Max. eigene Domains aufgeteilt nach Typ-Bucket */ /** Max. eigene Domains — EIN gemeinsamer Pool für web + mail (Infinity = unbegrenzt) */
customDomains: CustomDomainLimits; 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;
@ -84,33 +77,10 @@ export interface PlanLimits {
voice: VoiceConfig; voice: VoiceConfig;
} }
export const PLAN_LIMITS: Record<Plan, PlanLimits> = { // Free-Tier ist entfallen — es gibt nur noch Pro + Legend.
free: { export const PLAN_LIMITS: Record<Exclude<Plan, "free">, PlanLimits> = {
customDomains: { web: 5, mail: 5 },
domainRefill: false,
mailAgents: 1,
mailIntervalOptions: [4],
globalBlocklist: "curated",
canPost: true,
canCreateGroup: false,
canAddToBlocklist: false,
maxAppDevices: 1,
maxProtectedDevices: 0,
aiModel: "llama-3.1-8b-instant",
aiModelFallbacks: [
{ provider: "groq", model: "llama-3.3-70b-versatile" },
{ provider: "groq", model: "gemma2-9b-it" },
{ provider: "openrouter", model: "meta-llama/llama-3.1-8b-instruct" },
],
aiProvider: "groq",
voice: {
provider: "google",
model: "de-DE-Neural2-F", // Google Cloud TTS Neural2 — natural, ~$4/1M chars
dailyQuotaSeconds: 60, // 1 Minute/Tag
},
},
pro: { pro: {
customDomains: { web: 5, mail: 5 }, customDomains: 10,
domainRefill: true, domainRefill: true,
mailAgents: 3, mailAgents: 3,
mailIntervalOptions: [1, 4, 8], mailIntervalOptions: [1, 4, 8],
@ -133,7 +103,7 @@ export const PLAN_LIMITS: Record<Plan, PlanLimits> = {
}, },
}, },
legend: { legend: {
customDomains: { web: 10, mail: 10 }, customDomains: 20,
domainRefill: true, domainRefill: true,
mailAgents: Infinity, mailAgents: Infinity,
mailIntervalOptions: [1, 4, 8], mailIntervalOptions: [1, 4, 8],
@ -158,10 +128,10 @@ export const PLAN_LIMITS: Record<Plan, PlanLimits> = {
}; };
export function getPlanLimits(plan: string): PlanLimits { export function getPlanLimits(plan: string): PlanLimits {
// Legacy-Pläne auf neue Namen mappen // Free-Tier ist entfallen — alles außer Legend bekommt Pro-Limits.
if (plan === "premium") return PLAN_LIMITS.legend; // Legacy-Namen: premium → legend, standard → pro.
if (plan === "standard") return PLAN_LIMITS.pro; if (plan === "legend" || plan === "premium") return PLAN_LIMITS.legend;
return PLAN_LIMITS[(plan as Plan) ?? "free"] ?? PLAN_LIMITS.free; return PLAN_LIMITS.pro;
} }
/** /**