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:
parent
f19d00017a
commit
a2680f6e19
@ -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 };
|
||||
|
||||
@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user