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

146 lines
4.9 KiB
TypeScript

import { awardPoints } from "../../utils/scoring";
import {
addUserCustomDomain,
countActiveCustomDomainsSplit,
CUSTOM_DOMAIN_TYPES,
type CustomDomainType,
} from "../../db/domains";
import { getProfile } from "../../db/profile";
import { getPlanLimits } from "../../utils/plan-features";
// Regex: Domain muss mindestens eine TLD haben (z.B. "casino.de", "x.co.uk")
const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
// Display-Name-Pattern: Text ohne Punkte/Slashes (keine Domain-Syntax)
// Erlaubt: Buchstaben, Ziffern, Leerzeichen, Bindestrich, Unterstrich
const DISPLAY_NAME_PATTERN_RE = /^[a-zA-Z0-9\s\-_]+$/;
/**
* Leitet Frontend-`kind` auf internen `CustomDomainType` ab.
*
* Variante A (neues Frontend): { pattern: string, kind: 'web' | 'mail' }
* - kind='web' → type='web'
* - kind='mail' → analysiere pattern:
* enthält '.' + sieht wie Domain aus → 'mail_domain'
* sonst → 'mail_display_name'
*
* Variante B (direkt / Legacy): { domain: string, type: 'web' | 'mail_domain' | 'mail_display_name' }
*/
function resolveTypeAndValue(body: any): { type: CustomDomainType; value: string } {
// Variante A: pattern + kind
if (body?.kind !== undefined || body?.pattern !== undefined) {
const kind = (body?.kind as string)?.trim() ?? "web";
const pattern = (body?.pattern as string)?.trim() ?? "";
if (kind === "web") {
return { type: "web", value: pattern };
}
if (kind === "mail") {
// Domain-shape: enthält mindestens einen Punkt und passt auf Domain-Regex (nach lowercase)
const lower = pattern.toLowerCase().replace(/^https?:\/\//, "");
if (lower.includes(".") && DOMAIN_RE.test(lower)) {
return { type: "mail_domain", value: lower };
}
return { type: "mail_display_name", value: pattern };
}
// Unbekanntes kind → 400 via validTypes-Check unten
return { type: kind as CustomDomainType, value: pattern };
}
// Variante B: domain + type
const rawType = (body?.type as string)?.trim() ?? "web";
const value = (body?.domain as string)?.trim() ?? "";
return { type: rawType as CustomDomainType, value };
}
export default defineEventHandler(async (event) => {
const user = await requireUser(event);
const body = await readBody(event);
const { type, value: rawValue } = resolveTypeAndValue(body);
if (!CUSTOM_DOMAIN_TYPES.includes(type as CustomDomainType)) {
throw createError({
statusCode: 400,
data: { error: "INVALID_TYPE", validTypes: CUSTOM_DOMAIN_TYPES },
});
}
// domain/pattern validieren + normalisieren
let value = rawValue;
if (type === "mail_display_name") {
// Display-Name-Pattern: Case-sensitive gespeichert wie eingegeben,
// Matching erfolgt case-insensitiv. Keine Domain-Normalisierung.
if (!value || value.length < 2 || !DISPLAY_NAME_PATTERN_RE.test(value)) {
throw createError({
statusCode: 400,
data: { error: "INVALID_DISPLAY_NAME_PATTERN" },
});
}
// Sanity: kein Punkt/Slash → kein Domain-Format
if (value.includes(".") || value.includes("/")) {
throw createError({
statusCode: 400,
data: { error: "DISPLAY_NAME_LOOKS_LIKE_DOMAIN" },
});
}
} else {
// web und mail_domain: Domain-Validierung
value = value.toLowerCase().replace(/^https?:\/\//, "");
if (!value || !DOMAIN_RE.test(value)) {
throw createError({ statusCode: 400, data: { error: "INVALID_DOMAIN" } });
}
if (type === "mail_domain" && !value.includes(".")) {
throw createError({
statusCode: 400,
data: { error: "MAIL_DOMAIN_MISSING_TLD" },
});
}
}
// Per-type Slot-Limit prüfen
const profile = await getProfile(user.id);
const limits = getPlanLimits(profile?.plan ?? "free");
// Welcher Bucket?
const bucket: "web" | "mail" = type === "web" ? "web" : "mail";
const bucketLimit = limits.customDomains[bucket];
if (bucketLimit !== Infinity) {
const split = await countActiveCustomDomainsSplit(user.id);
const currentCount = split[bucket];
if (currentCount >= bucketLimit) {
const errorCode = bucket === "web" ? "WEB_LIMIT_REACHED" : "MAIL_LIMIT_REACHED";
throw createError({
statusCode: 403,
data: {
error: errorCode,
resource: "custom_domains",
bucket,
current: currentCount,
limit: bucketLimit,
},
});
}
}
try {
const data = await addUserCustomDomain(user.id, value, "manual", type);
await awardPoints(user.id, "custom_domain_submitted", { domain: value }).catch(
() => {},
);
return data;
} catch (err: any) {
const msg =
err.message?.includes("duplicate") || err.code === "P2002"
? "Eintrag bereits vorhanden"
: err.message ?? "Fehler";
throw createError({ statusCode: 400, message: msg });
}
});