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 { 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,
+34 -49
View File
@@ -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<DomainCheckResult[]> {
const tldsToCheck = tlds?.length ? tlds : DEFAULT_TLDS;
async checkDomain(input: string): Promise<DomainCheckResult[]> {
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<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) => {
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<DomainCheckResult> {
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<never>((_, 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 };
}
}
}
@@ -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[];
}
@@ -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<DomainCheckResponse> {
@@ -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);
}
};
@@ -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,14 +18,47 @@ export function ResultList({ results }: ResultListProps) {
);
}
const primary = results.filter((r) => r.primary);
const suggestions = results.filter((r) => !r.primary);
return (
<div className="space-y-4">
{primary.length > 0 && (
<div className="space-y-2">
{results.map((result) => (
{primary.map((result) => (
<ResultRow key={result.fqdn} result={result} />
))}
</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
key={result.fqdn}
className="flex items-center justify-between rounded-lg border border-border bg-card p-3"
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 font-medium">{result.fqdn}</span>
<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'
@@ -45,7 +71,5 @@ export function ResultList({ results }: ResultListProps) {
: t('statusRegistered')}
</span>
</div>
))}
</div>
);
}
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",