feat(backend): auto-detect kind from pattern when body omits kind/type

POST /api/custom-domains now accepts a third body variant — { pattern }
without an explicit kind or type — which the resolver infers from the
pattern shape so the frontend can ship a single dynamic input field
instead of asking the user to choose between Seite / E-Mail in advance.

- pattern contains '@'             → treat as kind='mail', strip the
                                     local-part, store as mail_domain
                                     after the same TLD / DOMAIN_RE
                                     validation as the explicit-kind path
- pattern contains '.' (no '@')   → treat as kind='web'
- neither                          → 400 INVALID_PATTERN with a clear
                                     message ("Bitte eine Domain oder
                                     Mail-Adresse eingeben")

Variant A ({ pattern, kind }) and Variant B ({ domain, type }) stay
fully supported, plus a `kind: 'auto'` keyword if a client prefers an
explicit opt-in to the auto-detect path. The display-name path is still
locked off in v1.0 — pure tokens without dots route into the same
INVALID_PATTERN response, which keeps the v1.0 guarantee intact.

Plan-limits.test.ts grew the matching test cases — auto-detect for a
domain, auto-detect for a full address (local-part stripped to mail_-
domain), auto-detect rejection for a bare token. All existing tests
keep their pass status.
This commit is contained in:
chahinebrini 2026-05-16 02:49:48 +02:00
parent f19d00017a
commit a2680f6e19
2 changed files with 138 additions and 5 deletions

View File

@ -24,6 +24,12 @@ const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])
* Variante B (direkt / Legacy): { domain: string, type: 'web' | 'mail_domain' | 'mail_display_name' }
* - type='mail_display_name' throws 400 DISPLAY_NAME_NOT_SUPPORTED (v1.0)
*
* Variante C (Auto-Detect): { pattern: string } ohne kind
* - kind fehlt oder kind='auto' Auto-Detect anhand pattern-Format
* - pattern.includes('@') wie kind='mail' (local-part strip, validate, mail_domain)
* - pattern.includes('.') && kein '@' wie kind='web' (validate, web)
* - weder '@' noch '.' 400 INVALID_PATTERN
*
* Display-Name-Blocking kommt in v1.1 mit eigener UX.
*
* Returns null wenn ein 400-Error geworfen werden soll caller wirft dann den Error
@ -31,14 +37,37 @@ const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])
*/
type ResolveResult =
| { ok: true; type: CustomDomainType; value: string }
| { ok: false; error: "INVALID_MAIL_DOMAIN" | "DISPLAY_NAME_NOT_SUPPORTED" };
| { ok: false; error: "INVALID_MAIL_DOMAIN" | "DISPLAY_NAME_NOT_SUPPORTED" | "INVALID_PATTERN" };
function resolveTypeAndValue(body: any): ResolveResult {
// Variante A: pattern + kind
// Variante A + C: pattern vorhanden (mit oder ohne kind)
if (body?.kind !== undefined || body?.pattern !== undefined) {
const kind = (body?.kind as string)?.trim() ?? "web";
const rawKind = (body?.kind as string)?.trim();
const pattern = (body?.pattern as string)?.trim() ?? "";
// Variante C: kind fehlt oder kind='auto' → Auto-Detect anhand pattern-Format
if (rawKind === undefined || rawKind === null || rawKind === "" || rawKind === "auto") {
if (pattern.includes("@")) {
// Wie kind='mail': local-part strippen, als mail_domain speichern
const atIdx = pattern.lastIndexOf("@");
const normalized = pattern.slice(atIdx + 1).toLowerCase().replace(/^https?:\/\//, "").trim();
if (normalized.includes(".") && DOMAIN_RE.test(normalized)) {
return { ok: true, type: "mail_domain", value: normalized };
}
return { ok: false, error: "INVALID_MAIL_DOMAIN" };
}
if (pattern.includes(".")) {
// Wie kind='web'
return { ok: true, type: "web", value: pattern };
}
// Weder '@' noch '.' → kein erkennbares Format
return { ok: false, error: "INVALID_PATTERN" };
}
const kind = rawKind;
if (kind === "web") {
return { ok: true, type: "web", value: pattern };
}
@ -104,6 +133,16 @@ export default defineEventHandler(async (event) => {
},
});
}
if (resolved.error === "INVALID_PATTERN") {
throw createError({
statusCode: 400,
data: {
error: "INVALID_PATTERN",
message:
"Bitte eine Domain (z.B. casino.com) oder Mail-Adresse (z.B. info@casino.com) eingeben.",
},
});
}
}
const { type, value: rawValue } = resolved as { ok: true; type: CustomDomainType; value: string };

View File

@ -62,15 +62,35 @@ describe("getPlanLimits — customDomains ist strukturiertes Objekt", () => {
*/
type TestResolveResult =
| { ok: true; type: string; value: string }
| { ok: false; error: "INVALID_MAIL_DOMAIN" | "DISPLAY_NAME_NOT_SUPPORTED" };
| { ok: false; error: "INVALID_MAIL_DOMAIN" | "DISPLAY_NAME_NOT_SUPPORTED" | "INVALID_PATTERN" };
function resolveTypeAndValueForTest(body: any): TestResolveResult {
const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
if (body?.kind !== undefined || body?.pattern !== undefined) {
const kind = (body?.kind as string)?.trim() ?? "web";
const rawKind = (body?.kind as string)?.trim();
const pattern = (body?.pattern as string)?.trim() ?? "";
// Variante C: kind fehlt oder kind='auto' → Auto-Detect
if (rawKind === undefined || rawKind === null || rawKind === "" || rawKind === "auto") {
if (pattern.includes("@")) {
const atIdx = pattern.lastIndexOf("@");
const normalized = pattern.slice(atIdx + 1).toLowerCase().replace(/^https?:\/\//, "").trim();
if (normalized.includes(".") && DOMAIN_RE.test(normalized)) {
return { ok: true, type: "mail_domain", value: normalized };
}
return { ok: false, error: "INVALID_MAIL_DOMAIN" };
}
if (pattern.includes(".")) {
return { ok: true, type: "web", value: pattern };
}
return { ok: false, error: "INVALID_PATTERN" };
}
const kind = rawKind;
if (kind === "web") {
return { ok: true, type: "web", value: pattern };
}
@ -322,3 +342,77 @@ describe("Submit-Guard — DISPLAY_NAME_NOT_SUBMITTABLE", () => {
expect(isSubmittable).toBe(true);
});
});
// ─── Variante C — Auto-Detect (kein kind im Body) ────────────────────────────
describe("Auto-Detect — { pattern } ohne kind (Variante C)", () => {
it("{ pattern: 'casino.com' } → type='web', value='casino.com'", () => {
const r = resolveTypeAndValueForTest({ pattern: "casino.com" });
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.type).toBe("web");
expect(r.value).toBe("casino.com");
}
});
it("{ pattern: 'info@casino.com' } → type='mail_domain', value='casino.com'", () => {
const r = resolveTypeAndValueForTest({ pattern: "info@casino.com" });
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.type).toBe("mail_domain");
expect(r.value).toBe("casino.com");
}
});
it("{ pattern: 'INVALID' } → 400 INVALID_PATTERN (kein '@', kein '.')", () => {
const r = resolveTypeAndValueForTest({ pattern: "INVALID" });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toBe("INVALID_PATTERN");
});
it("{ pattern: 'a.b@c.d' } → type='mail_domain', value='c.d' (lastIndexOf @ strip)", () => {
const r = resolveTypeAndValueForTest({ pattern: "a.b@c.d" });
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.type).toBe("mail_domain");
expect(r.value).toBe("c.d");
}
});
it("{ kind: 'auto', pattern: 'casino.com' } → type='web' (kind='auto' triggert Auto-Detect)", () => {
const r = resolveTypeAndValueForTest({ kind: "auto", pattern: "casino.com" });
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.type).toBe("web");
expect(r.value).toBe("casino.com");
}
});
it("{ kind: 'auto', pattern: 'no@valid.domain.here' } → type='mail_domain', value='valid.domain.here'", () => {
const r = resolveTypeAndValueForTest({ kind: "auto", pattern: "no@valid.domain.here" });
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.type).toBe("mail_domain");
expect(r.value).toBe("valid.domain.here");
}
});
// Bestehende Variante A bleibt unberührt (Backwards-Compat)
it("Backwards-Compat: { kind: 'web', pattern: 'casino.de' } → type='web' (weiterhin)", () => {
const r = resolveTypeAndValueForTest({ kind: "web", pattern: "casino.de" });
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.type).toBe("web");
expect(r.value).toBe("casino.de");
}
});
it("Backwards-Compat: { kind: 'mail', pattern: 'news@bet365.de' } → type='mail_domain', value='bet365.de'", () => {
const r = resolveTypeAndValueForTest({ kind: "mail", pattern: "news@bet365.de" });
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.type).toBe("mail_domain");
expect(r.value).toBe("bet365.de");
}
});
});