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.
146 lines
4.9 KiB
TypeScript
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 });
|
|
}
|
|
});
|