feat(domaincheck): support all TLDs with suggestion alternatives
Tessera CI/CD / Lint & Type Check (push) Successful in 1m8s
Tessera CI/CD / Tests (push) Successful in 1m13s
Tessera CI/CD / Build & Deploy (push) Successful in 2m39s

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 14:54:30 +02:00
parent 95423497ad
commit 9f78580606
8 changed files with 95 additions and 134 deletions
@@ -3,32 +3,14 @@ import { UseModule } from '../module-registry/module.guard';
import { CheckDomainDto } from './dto/check-domain.dto'; import { CheckDomainDto } from './dto/check-domain.dto';
import { DomaincheckService } from './domaincheck.service'; 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') @Controller('modules/domaincheck')
@UseModule('domaincheck') @UseModule('domaincheck')
export class DomaincheckController { export class DomaincheckController {
constructor(private readonly domaincheckService: DomaincheckService) {} 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') @Post('check')
async checkDomain(@Body() dto: CheckDomainDto) { async checkDomain(@Body() dto: CheckDomainDto) {
const results = await this.domaincheckService.checkDomain( const results = await this.domaincheckService.checkDomain(dto.domain);
dto.domain,
dto.tlds,
);
return { return {
domain: dto.domain, domain: dto.domain,
+34 -49
View File
@@ -1,82 +1,69 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import * as dns from 'node:dns/promises'; import * as dns from 'node:dns/promises';
/**
* Status of a domain+TLD availability check.
*/
export interface DomainCheckResult { export interface DomainCheckResult {
tld: string; tld: string;
fqdn: string; fqdn: string;
status: 'available' | 'registered'; status: 'available' | 'registered';
primary: boolean;
} }
/** const SUGGESTION_TLDS = ['de', 'com', 'net', 'org'];
* Default TLD list for domain checks (per D-03).
*/
const DEFAULT_TLDS = ['de', 'com', 'net', 'org'];
/**
* Per T-03-06: timeout per individual DNS lookup (milliseconds).
*/
const DNS_TIMEOUT_MS = 5000; 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() @Injectable()
export class DomaincheckService { export class DomaincheckService {
private readonly logger = new Logger(DomaincheckService.name); private readonly logger = new Logger(DomaincheckService.name);
/** async checkDomain(input: string): Promise<DomainCheckResult[]> {
* Check availability of a domain across multiple TLDs. const dotIndex = input.indexOf('.');
* let label: string;
* @param domain - Base domain label (e.g., "example") let primaryTld: string | null = null;
* @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<DomainCheckResult[]> {
const tldsToCheck = tlds?.length ? tlds : DEFAULT_TLDS;
// Per T-03-06: Promise.allSettled prevents one lookup from blocking others if (dotIndex > 0) {
const results = await Promise.allSettled( label = input.substring(0, dotIndex);
tldsToCheck.map((tld) => this.checkSingleDomain(domain, tld)), primaryTld = input.substring(dotIndex + 1).toLowerCase();
); } else {
label = input;
}
const suggestionTlds = SUGGESTION_TLDS.filter((t) => t !== primaryTld);
const allChecks: Promise<DomainCheckResult>[] = [];
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) => { return results.map((result, index) => {
if (result.status === 'fulfilled') { if (result.status === 'fulfilled') {
return result.value; return result.value;
} }
// If Promise.allSettled rejects (shouldn't happen with our try/catch), const tld = index === 0 && primaryTld ? primaryTld : suggestionTlds[primaryTld ? index - 1 : index];
// treat as registered (safe default per plan)
return { return {
tld: tldsToCheck[index], tld,
fqdn: `${domain}.${tldsToCheck[index]}`, fqdn: `${label}.${tld}`,
status: 'registered' as const, 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( private async checkSingleDomain(
domain: string, domain: string,
tld: string, tld: string,
primary: boolean,
): Promise<DomainCheckResult> { ): Promise<DomainCheckResult> {
const fqdn = `${domain}.${tld}`; const fqdn = `${domain}.${tld}`;
try { try {
// Per T-03-06: 5-second timeout per lookup to prevent hanging
await Promise.race([ await Promise.race([
dns.resolve(fqdn), dns.resolve(fqdn),
new Promise<never>((_, reject) => new Promise<never>((_, reject) =>
@@ -87,8 +74,7 @@ export class DomaincheckService {
), ),
]); ]);
// DNS resolved successfully — domain is registered return { tld, fqdn, status: 'registered', primary };
return { tld, fqdn, status: 'registered' };
} catch (error: unknown) { } catch (error: unknown) {
const code = const code =
error instanceof Error && 'code' in error error instanceof Error && 'code' in error
@@ -96,12 +82,11 @@ export class DomaincheckService {
: undefined; : undefined;
if (code === 'ENOTFOUND' || code === 'ENODATA') { 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}`); this.logger.warn(`DNS check for ${fqdn} failed: ${error}`);
return { tld, fqdn, status: 'registered' }; return { tld, fqdn, status: 'registered', primary };
} }
} }
} }
@@ -1,8 +1,5 @@
import { import {
ArrayMaxSize,
IsArray,
IsNotEmpty, IsNotEmpty,
IsOptional,
IsString, IsString,
Matches, Matches,
} from 'class-validator'; } from 'class-validator';
@@ -16,19 +13,9 @@ import {
export class CheckDomainDto { export class CheckDomainDto {
@IsString() @IsString()
@IsNotEmpty() @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: 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; 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[];
} }
@@ -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'; const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
export interface DomainCheckResult { export interface DomainCheckResult {
tld: string; tld: string;
fqdn: string; fqdn: string;
status: 'available' | 'registered'; status: 'available' | 'registered';
primary: boolean;
} }
export interface DomainCheckResponse { export interface DomainCheckResponse {
@@ -18,13 +12,6 @@ export interface DomainCheckResponse {
results: DomainCheckResult[]; 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( export async function checkDomainAction(
domain: string, domain: string,
): Promise<DomainCheckResponse> { ): Promise<DomainCheckResponse> {
@@ -8,21 +8,15 @@ interface DomainInputProps {
isLoading: boolean; 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) { export function DomainInput({ onSubmit, isLoading }: DomainInputProps) {
const t = useTranslations('domaincheck'); const t = useTranslations('domaincheck');
const [domain, setDomain] = useState(''); const [domain, setDomain] = useState('');
const handleSubmit = (e: FormEvent) => { const handleSubmit = (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
const label = domain.trim().split('.')[0]; const trimmed = domain.trim().toLowerCase();
if (label) { if (trimmed) {
onSubmit(label); onSubmit(trimmed);
} }
}; };
@@ -7,13 +7,6 @@ interface ResultListProps {
results: DomainCheckResult[]; 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) { export function ResultList({ results }: ResultListProps) {
const t = useTranslations('domaincheck'); 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 ( return (
<div className="space-y-2"> <div className="space-y-4">
{results.map((result) => ( {primary.length > 0 && (
<div <div className="space-y-2">
key={result.fqdn} {primary.map((result) => (
className="flex items-center justify-between rounded-lg border border-border bg-card p-3" <ResultRow key={result.fqdn} result={result} />
> ))}
<span className="text-sm font-medium">{result.fqdn}</span>
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${
result.status === 'available'
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'
}`}
>
{result.status === 'available'
? t('statusAvailable')
: t('statusRegistered')}
</span>
</div> </div>
))} )}
{suggestions.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
{t('suggestions')}
</p>
{suggestions.map((result) => (
<ResultRow key={result.fqdn} result={result} />
))}
</div>
)}
</div>
);
}
function ResultRow({ result }: { result: DomainCheckResult }) {
const t = useTranslations('domaincheck');
return (
<div
className={`flex items-center justify-between rounded-lg border p-3 ${
result.primary
? 'border-primary/30 bg-primary/5'
: 'border-border bg-card'
}`}
>
<span className={`text-sm ${result.primary ? 'font-semibold' : 'font-medium'}`}>
{result.fqdn}
</span>
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${
result.status === 'available'
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'
}`}
>
{result.status === 'available'
? t('statusAvailable')
: t('statusRegistered')}
</span>
</div> </div>
); );
} }
+2 -1
View File
@@ -221,7 +221,8 @@
"domaincheck": { "domaincheck": {
"title": "Domaincheck", "title": "Domaincheck",
"description": "Pruefe ob eine Domain verfuegbar ist", "description": "Pruefe ob eine Domain verfuegbar ist",
"inputPlaceholder": "Domain eingeben (z.B. beispiel)", "inputPlaceholder": "Domain eingeben (z.B. beispiel.de)",
"suggestions": "Alternativen",
"checkButton": "Pruefen", "checkButton": "Pruefen",
"statusAvailable": "Verfuegbar", "statusAvailable": "Verfuegbar",
"statusRegistered": "Registriert", "statusRegistered": "Registriert",
+2 -1
View File
@@ -221,7 +221,8 @@
"domaincheck": { "domaincheck": {
"title": "Domain Check", "title": "Domain Check",
"description": "Check if a domain is available", "description": "Check if a domain is available",
"inputPlaceholder": "Enter domain (e.g. example)", "inputPlaceholder": "Enter domain (e.g. example.com)",
"suggestions": "Alternatives",
"checkButton": "Check", "checkButton": "Check",
"statusAvailable": "Available", "statusAvailable": "Available",
"statusRegistered": "Registered", "statusRegistered": "Registered",