chahinebrini f2b81eef54 feat(backend/plan): separate web/mail slot pools + display-name submit lock
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.
2026-05-16 02:03:26 +02:00

42 lines
1.5 KiB
TypeScript

import { getProfile } from "../../db/profile";
import { getPlanLimits } from "../../utils/plan-features";
export default defineEventHandler(async (event) => {
const user = await requireUser(event);
const dbProfile = await getProfile(user.id);
const plan = (dbProfile?.plan === "premium"
? "legend"
: dbProfile?.plan === "standard"
? "pro"
: dbProfile?.plan ?? "free") as "free" | "pro" | "legend";
const limits = getPlanLimits(plan);
return {
id: user.id,
email: user.email,
username: dbProfile?.username ?? "",
nickname: dbProfile?.nickname ?? null,
avatar: dbProfile?.avatar ?? null,
plan,
foundingMember: dbProfile?.foundingMember ?? false,
streak: dbProfile?.streak ?? 0,
lyraVoiceId: dbProfile?.lyraVoiceId ?? null,
created_at: dbProfile?.createdAt?.toISOString() ?? user.created_at,
// Für useUserPlan im Frontend — Key-Subset der PlanLimits
planLimits: {
customDomains: limits.customDomains, // { web: number, mail: number }
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,
};
});