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

89 lines
3.1 KiB
TypeScript

import { submitDomainForReview } from "../../../db/domains";
import { getProfile } from "../../../db/profile";
import { getPlanLimits } from "../../../utils/plan-features";
import { usePrisma } from "../../../utils/prisma";
export default defineEventHandler(async (event) => {
const user = await requireUser(event);
const id = getRouterParam(event, "id");
if (!id) throw createError({ statusCode: 400, message: "ID fehlt" });
// Only Pro/Legend can submit
const profile = await getProfile(user.id);
const plan = profile?.plan ?? "free";
const limits = getPlanLimits(plan);
if (!limits.domainRefill) {
throw createError({
statusCode: 403,
message: "Nur Pro-User können Domains einreichen",
});
}
const db = usePrisma();
// Verify ownership + status
const existing = await db.userCustomDomain.findFirst({
where: { id, userId: user.id },
select: { id: true, domain: true, status: true, type: true },
});
if (!existing)
throw createError({ statusCode: 404, message: "Domain nicht gefunden" });
if (existing.status !== "active" && existing.status !== "rejected") {
throw createError({
statusCode: 409,
message: "Domain wurde bereits eingereicht oder genehmigt",
});
}
// v1.0: Display-Name-Patterns sind nicht submittable (keine BlocklistDomain-Erweiterung vor TestFlight)
if (existing.type === "mail_display_name") {
throw createError({
statusCode: 400,
data: {
error: "DISPLAY_NAME_NOT_SUBMITTABLE",
message:
"Display-name patterns cannot be submitted to the global blocklist in v1.0. Use them as user-private filters only.",
},
});
}
// Tier-Routing:
// - Pro: Community-Post mit Voting-Flow erstellen
// - Legend: KEIN Post — Domain/Pattern landet direkt in der Admin-Queue
//
// Für mail_display_name: domain-Feld enthält das Pattern-String (kein PII).
// Admin-Review erkennt type via customDomain.type-Feld.
let postId: string | null = null;
if (plan === "pro") {
const isDisplayName = existing.type === "mail_display_name";
const label = isDisplayName ? "Display-Name-Pattern" : "Domain";
const postContent = isDisplayName
? `Domain-Vorschlag (Display-Name-Pattern): **${existing.domain}**\n\nIch schlage vor, dieses Absender-Muster zur globalen ReBreak-Sperrliste hinzuzufügen. Stimme ab: Sollte **${existing.domain}** global gesperrt werden?`
: `Domain-Vorschlag: **${existing.domain}**\n\nIch schlage vor, diese Domain zur globalen ReBreak-Sperrliste hinzuzufügen. Stimme ab: Sollte **${existing.domain}** global gesperrt werden?`;
const post = await db.communityPost.create({
data: {
userId: user.id,
category: "domain_vote",
content: postContent,
},
select: { id: true },
});
postId = post.id;
}
const { submission } = await submitDomainForReview(
user.id,
id,
plan as "free" | "pro" | "legend",
postId ?? undefined,
);
return {
ok: true,
postId,
submissionId: submission.id,
domain: existing.domain,
type: existing.type,
route: plan === "legend" ? "admin_direct" : "community_vote",
};
});