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 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user