From 9f7858060632ca2ba0d0aabb433e9d77e39376a3 Mon Sep 17 00:00:00 2001 From: Schalli Date: Thu, 25 Jun 2026 14:54:30 +0200 Subject: [PATCH] feat(domaincheck): support all TLDs with suggestion alternatives - Accept full domains (e.g. "example.xyz") not just labels - Check the entered TLD as primary result - Show .de, .com, .net, .org as alternative suggestions below - Primary result highlighted with accent border - Input without TLD still works (shows all 4 suggestions) - Updated i18n placeholders and added "suggestions" label Co-Authored-By: Claude Sonnet 4.6 --- .../src/domaincheck/domaincheck.controller.ts | 20 +---- .../src/domaincheck/domaincheck.service.ts | 83 ++++++++----------- .../src/domaincheck/dto/check-domain.dto.ts | 17 +--- .../(portal)/modules/domaincheck/actions.ts | 15 +--- .../domaincheck/components/DomainInput.tsx | 12 +-- .../domaincheck/components/ResultList.tsx | 76 +++++++++++------ apps/web/src/messages/de.json | 3 +- apps/web/src/messages/en.json | 3 +- 8 files changed, 95 insertions(+), 134 deletions(-) diff --git a/apps/api/src/domaincheck/domaincheck.controller.ts b/apps/api/src/domaincheck/domaincheck.controller.ts index 4cc69e5..63c4b74 100644 --- a/apps/api/src/domaincheck/domaincheck.controller.ts +++ b/apps/api/src/domaincheck/domaincheck.controller.ts @@ -3,32 +3,14 @@ import { UseModule } from '../module-registry/module.guard'; import { CheckDomainDto } from './dto/check-domain.dto'; import { DomaincheckService } from './domaincheck.service'; -/** - * Controller for the Domaincheck module. - * - * POST /modules/domaincheck/check — accepts a domain label, checks TLD - * variants via DNS, and returns availability status for each. - * - * Protected by @UseModule('domaincheck') which ensures the requesting - * tenant has the domaincheck module activated (per T-03-08). - */ @Controller('modules/domaincheck') @UseModule('domaincheck') export class DomaincheckController { constructor(private readonly domaincheckService: DomaincheckService) {} - /** - * Check domain availability across TLD variants. - * - * @param dto - Contains domain label and optional TLD list - * @returns Domain and results array with tld, fqdn, and status - */ @Post('check') async checkDomain(@Body() dto: CheckDomainDto) { - const results = await this.domaincheckService.checkDomain( - dto.domain, - dto.tlds, - ); + const results = await this.domaincheckService.checkDomain(dto.domain); return { domain: dto.domain, diff --git a/apps/api/src/domaincheck/domaincheck.service.ts b/apps/api/src/domaincheck/domaincheck.service.ts index 28427f0..c512ac6 100644 --- a/apps/api/src/domaincheck/domaincheck.service.ts +++ b/apps/api/src/domaincheck/domaincheck.service.ts @@ -1,82 +1,69 @@ import { Injectable, Logger } from '@nestjs/common'; import * as dns from 'node:dns/promises'; -/** - * Status of a domain+TLD availability check. - */ export interface DomainCheckResult { tld: string; fqdn: string; status: 'available' | 'registered'; + primary: boolean; } -/** - * Default TLD list for domain checks (per D-03). - */ -const DEFAULT_TLDS = ['de', 'com', 'net', 'org']; +const SUGGESTION_TLDS = ['de', 'com', 'net', 'org']; -/** - * Per T-03-06: timeout per individual DNS lookup (milliseconds). - */ const DNS_TIMEOUT_MS = 5000; -/** - * Service that checks domain availability across TLD variants via DNS. - * - * Per D-02: the system checks all TLD variants automatically from a base - * domain name. Uses dns.resolve from node:dns/promises for each variant. - */ @Injectable() export class DomaincheckService { private readonly logger = new Logger(DomaincheckService.name); - /** - * Check availability of a domain across multiple TLDs. - * - * @param domain - Base domain label (e.g., "example") - * @param tlds - Optional TLD list; defaults to DEFAULT_TLDS - * @returns Array of results with tld, fqdn, and availability status - */ - async checkDomain( - domain: string, - tlds?: string[], - ): Promise { - const tldsToCheck = tlds?.length ? tlds : DEFAULT_TLDS; + async checkDomain(input: string): Promise { + const dotIndex = input.indexOf('.'); + let label: string; + let primaryTld: string | null = null; - // Per T-03-06: Promise.allSettled prevents one lookup from blocking others - const results = await Promise.allSettled( - tldsToCheck.map((tld) => this.checkSingleDomain(domain, tld)), - ); + if (dotIndex > 0) { + label = input.substring(0, dotIndex); + primaryTld = input.substring(dotIndex + 1).toLowerCase(); + } else { + label = input; + } + + const suggestionTlds = SUGGESTION_TLDS.filter((t) => t !== primaryTld); + + const allChecks: Promise[] = []; + + if (primaryTld) { + allChecks.push(this.checkSingleDomain(label, primaryTld, true)); + } + + for (const tld of suggestionTlds) { + allChecks.push(this.checkSingleDomain(label, tld, false)); + } + + const results = await Promise.allSettled(allChecks); return results.map((result, index) => { if (result.status === 'fulfilled') { return result.value; } - // If Promise.allSettled rejects (shouldn't happen with our try/catch), - // treat as registered (safe default per plan) + const tld = index === 0 && primaryTld ? primaryTld : suggestionTlds[primaryTld ? index - 1 : index]; return { - tld: tldsToCheck[index], - fqdn: `${domain}.${tldsToCheck[index]}`, + tld, + fqdn: `${label}.${tld}`, status: 'registered' as const, + primary: index === 0 && !!primaryTld, }; }); } - /** - * Check a single domain+TLD combination via DNS resolution. - * - * - If resolves (any record) -> 'registered' - * - If ENOTFOUND or ENODATA -> 'available' - * - Any other error -> 'registered' (safe default) - */ private async checkSingleDomain( domain: string, tld: string, + primary: boolean, ): Promise { const fqdn = `${domain}.${tld}`; try { - // Per T-03-06: 5-second timeout per lookup to prevent hanging await Promise.race([ dns.resolve(fqdn), new Promise((_, reject) => @@ -87,8 +74,7 @@ export class DomaincheckService { ), ]); - // DNS resolved successfully — domain is registered - return { tld, fqdn, status: 'registered' }; + return { tld, fqdn, status: 'registered', primary }; } catch (error: unknown) { const code = error instanceof Error && 'code' in error @@ -96,12 +82,11 @@ export class DomaincheckService { : undefined; if (code === 'ENOTFOUND' || code === 'ENODATA') { - return { tld, fqdn, status: 'available' }; + return { tld, fqdn, status: 'available', primary }; } - // Timeout or other network error — safe default is 'registered' this.logger.warn(`DNS check for ${fqdn} failed: ${error}`); - return { tld, fqdn, status: 'registered' }; + return { tld, fqdn, status: 'registered', primary }; } } } diff --git a/apps/api/src/domaincheck/dto/check-domain.dto.ts b/apps/api/src/domaincheck/dto/check-domain.dto.ts index 7b0a950..9cbfbd0 100644 --- a/apps/api/src/domaincheck/dto/check-domain.dto.ts +++ b/apps/api/src/domaincheck/dto/check-domain.dto.ts @@ -1,8 +1,5 @@ import { - ArrayMaxSize, - IsArray, IsNotEmpty, - IsOptional, IsString, Matches, } from 'class-validator'; @@ -16,19 +13,9 @@ import { export class CheckDomainDto { @IsString() @IsNotEmpty() - @Matches(/^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/, { + @Matches(/^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z]{2,})?$/, { message: - 'Domain must be a valid label: alphanumeric and hyphens only, 1-63 characters, cannot start or end with a hyphen', + 'Domain must be a valid label (e.g. "example") or full domain (e.g. "example.com")', }) domain!: string; - - /** - * Optional list of TLDs to check. If omitted, defaults are used. - * Per T-03-06: max 10 TLDs per request to prevent DoS. - */ - @IsOptional() - @IsArray() - @IsString({ each: true }) - @ArrayMaxSize(10, { message: 'Maximum 10 TLDs per request' }) - tlds?: string[]; } diff --git a/apps/web/src/app/(portal)/modules/domaincheck/actions.ts b/apps/web/src/app/(portal)/modules/domaincheck/actions.ts index b1b4673..0f852d5 100644 --- a/apps/web/src/app/(portal)/modules/domaincheck/actions.ts +++ b/apps/web/src/app/(portal)/modules/domaincheck/actions.ts @@ -1,16 +1,10 @@ -/** - * Client-side action to check domain availability via the API. - * - * Calls POST /modules/domaincheck/check with the domain label. - * Auth cookie is forwarded via credentials: 'include'. - */ - const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; export interface DomainCheckResult { tld: string; fqdn: string; status: 'available' | 'registered'; + primary: boolean; } export interface DomainCheckResponse { @@ -18,13 +12,6 @@ export interface DomainCheckResponse { results: DomainCheckResult[]; } -/** - * Check domain availability across TLD variants. - * - * @param domain - Base domain label (e.g., "example") - * @returns Response with domain and results array - * @throws Error if the API call fails - */ export async function checkDomainAction( domain: string, ): Promise { diff --git a/apps/web/src/app/(portal)/modules/domaincheck/components/DomainInput.tsx b/apps/web/src/app/(portal)/modules/domaincheck/components/DomainInput.tsx index 079ee2a..6c40241 100644 --- a/apps/web/src/app/(portal)/modules/domaincheck/components/DomainInput.tsx +++ b/apps/web/src/app/(portal)/modules/domaincheck/components/DomainInput.tsx @@ -8,21 +8,15 @@ interface DomainInputProps { isLoading: boolean; } -/** - * Input form for entering a domain label to check. - * - * Renders a text input with placeholder from i18n and a submit button - * that shows loading state while the check is in progress. - */ export function DomainInput({ onSubmit, isLoading }: DomainInputProps) { const t = useTranslations('domaincheck'); const [domain, setDomain] = useState(''); const handleSubmit = (e: FormEvent) => { e.preventDefault(); - const label = domain.trim().split('.')[0]; - if (label) { - onSubmit(label); + const trimmed = domain.trim().toLowerCase(); + if (trimmed) { + onSubmit(trimmed); } }; diff --git a/apps/web/src/app/(portal)/modules/domaincheck/components/ResultList.tsx b/apps/web/src/app/(portal)/modules/domaincheck/components/ResultList.tsx index d3693a2..08cf6e4 100644 --- a/apps/web/src/app/(portal)/modules/domaincheck/components/ResultList.tsx +++ b/apps/web/src/app/(portal)/modules/domaincheck/components/ResultList.tsx @@ -7,13 +7,6 @@ interface ResultListProps { results: DomainCheckResult[]; } -/** - * Displays domain availability results as a list with color-coded status. - * - * Per D-01: green badge for "available", red badge for "registered". - * Per D-02: shows all TLD variants with their status. - * Shows empty state message when no results are available. - */ export function ResultList({ results }: ResultListProps) { const t = useTranslations('domaincheck'); @@ -25,27 +18,58 @@ export function ResultList({ results }: ResultListProps) { ); } + const primary = results.filter((r) => r.primary); + const suggestions = results.filter((r) => !r.primary); + return ( -
- {results.map((result) => ( -
- {result.fqdn} - - {result.status === 'available' - ? t('statusAvailable') - : t('statusRegistered')} - +
+ {primary.length > 0 && ( +
+ {primary.map((result) => ( + + ))}
- ))} + )} + + {suggestions.length > 0 && ( +
+

+ {t('suggestions')} +

+ {suggestions.map((result) => ( + + ))} +
+ )} +
+ ); +} + +function ResultRow({ result }: { result: DomainCheckResult }) { + const t = useTranslations('domaincheck'); + + return ( +
+ + {result.fqdn} + + + {result.status === 'available' + ? t('statusAvailable') + : t('statusRegistered')} +
); } diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index 4d06128..e2a814c 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -221,7 +221,8 @@ "domaincheck": { "title": "Domaincheck", "description": "Pruefe ob eine Domain verfuegbar ist", - "inputPlaceholder": "Domain eingeben (z.B. beispiel)", + "inputPlaceholder": "Domain eingeben (z.B. beispiel.de)", + "suggestions": "Alternativen", "checkButton": "Pruefen", "statusAvailable": "Verfuegbar", "statusRegistered": "Registriert", diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index 0076f38..6deef92 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -221,7 +221,8 @@ "domaincheck": { "title": "Domain Check", "description": "Check if a domain is available", - "inputPlaceholder": "Enter domain (e.g. example)", + "inputPlaceholder": "Enter domain (e.g. example.com)", + "suggestions": "Alternatives", "checkButton": "Check", "statusAvailable": "Available", "statusRegistered": "Registered",