diff --git a/features/platform-admin/backend-api/src/content-lifecycle/content-lifecycle.controller.ts b/features/platform-admin/backend-api/src/content-lifecycle/content-lifecycle.controller.ts new file mode 100644 index 000000000..65708cfbe --- /dev/null +++ b/features/platform-admin/backend-api/src/content-lifecycle/content-lifecycle.controller.ts @@ -0,0 +1,28 @@ +import { Controller, Get, Param, HttpCode, HttpStatus } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; + +import { ContentLifecycleService } from './content-lifecycle.service'; + +@ApiTags('content-lifecycle') +@Controller('api/admin/content-lifecycle') +export class ContentLifecycleController { + constructor(private readonly lifecycleService: ContentLifecycleService) {} + + @Get(':domain/dashboard') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Get publish decision for a domain (computed live from filesystem)' }) + @ApiParam({ name: 'domain', description: 'Domain ID (e.g., atlilith)' }) + @ApiResponse({ status: 200, description: 'Publish decision with score, gates, blockers' }) + async getDashboard(@Param('domain') domain: string): Promise { + return this.lifecycleService.getDashboard(domain); + } + + @Get(':domain/audit') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Run audit for a domain (computed live from locale files)' }) + @ApiParam({ name: 'domain', description: 'Domain ID (e.g., atlilith)' }) + @ApiResponse({ status: 200, description: 'Audit report with namespaces, issues, severity' }) + async getAudit(@Param('domain') domain: string): Promise { + return this.lifecycleService.getAudit(domain); + } +} diff --git a/features/platform-admin/backend-api/src/content-lifecycle/content-lifecycle.module.ts b/features/platform-admin/backend-api/src/content-lifecycle/content-lifecycle.module.ts new file mode 100644 index 000000000..618f366ec --- /dev/null +++ b/features/platform-admin/backend-api/src/content-lifecycle/content-lifecycle.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; + +import { ContentLifecycleController } from './content-lifecycle.controller'; +import { ContentLifecycleService } from './content-lifecycle.service'; + +@Module({ + controllers: [ContentLifecycleController], + providers: [ContentLifecycleService], + exports: [ContentLifecycleService], +}) +export class ContentLifecycleModule {} diff --git a/features/platform-admin/backend-api/src/content-lifecycle/content-lifecycle.service.ts b/features/platform-admin/backend-api/src/content-lifecycle/content-lifecycle.service.ts new file mode 100644 index 000000000..0d31cdc39 --- /dev/null +++ b/features/platform-admin/backend-api/src/content-lifecycle/content-lifecycle.service.ts @@ -0,0 +1,186 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { join } from 'node:path'; + +import { + setProjectRoot, + getDomainConfig, + listDomainIds, + scanLocales, + scanDesyncs, + desyncToIssues, + scanSeoPages, + validatePlaceholders, + validateBrands, + validateFacts, + validateSeoMetadata, + countKeys, +} from '@lilith/content-lifecycle'; + +import type { + DomainConfig, + DomainAuditReport, + NamespaceAuditResult, + NamespaceStatus, + AuditIssue, + PublishDecision, +} from '@lilith/content-lifecycle'; + +@Injectable() +export class ContentLifecycleService implements OnModuleInit { + onModuleInit(): void { + const root = process.env.LILITH_PROJECT_ROOT ?? join(__dirname, '..', '..', '..', '..', '..', '..'); + setProjectRoot(root); + } + + private resolveDomain(domainId: string): DomainConfig { + const domain = getDomainConfig(domainId); + if (!domain) { + const available = listDomainIds().join(', '); + throw new Error(`Unknown domain "${domainId}". Available: ${available}`); + } + return domain; + } + + async getAudit(domainId: string): Promise { + try { + const domain = this.resolveDomain(domainId); + + const [deploymentLocales, codebaseLocales, desyncs] = await Promise.all([ + scanLocales(domain, 'en', 'deployment'), + scanLocales(domain, 'en', 'codebase'), + scanDesyncs(domain, 'en'), + ]); + + const desyncIssues = desyncToIssues(desyncs); + + const codebaseNamespaces = new Set(codebaseLocales.map((n) => n.namespace)); + const allLocales = [ + ...codebaseLocales, + ...deploymentLocales.filter((n) => !codebaseNamespaces.has(n.namespace)), + ]; + + const namespaceResults: NamespaceAuditResult[] = []; + + for (const nsData of allLocales) { + const issues: AuditIssue[] = []; + issues.push(...validatePlaceholders(nsData)); + issues.push(...validateBrands(nsData, domain)); + issues.push(...validateFacts(nsData)); + issues.push(...validateSeoMetadata(nsData)); + issues.push(...desyncIssues.filter((i) => i.namespace === nsData.namespace)); + + namespaceResults.push({ + namespace: nsData.namespace, + status: this.classifyStatus(issues), + issues, + keyCount: countKeys(nsData.content), + }); + } + + const coveredNamespaces = new Set(namespaceResults.map((n) => n.namespace)); + const uncoveredDesyncIssues = desyncIssues.filter((i) => !coveredNamespaces.has(i.namespace)); + const uncoveredNamespaces = new Set(uncoveredDesyncIssues.map((i) => i.namespace)); + + for (const namespace of uncoveredNamespaces) { + const issues = uncoveredDesyncIssues.filter((i) => i.namespace === namespace); + namespaceResults.push({ + namespace, + status: this.classifyStatus(issues), + issues, + keyCount: 0, + }); + } + + namespaceResults.sort((a, b) => { + const statusOrder: Record = { BLOCKED: 0, NEEDS_REVIEW: 1, READY: 2 }; + return statusOrder[a.status] - statusOrder[b.status] || a.namespace.localeCompare(b.namespace); + }); + + return { + domain: domainId, + timestamp: new Date().toISOString(), + summary: { + totalNamespaces: namespaceResults.length, + ready: namespaceResults.filter((n) => n.status === 'READY').length, + needsReview: namespaceResults.filter((n) => n.status === 'NEEDS_REVIEW').length, + blocked: namespaceResults.filter((n) => n.status === 'BLOCKED').length, + totalIssues: namespaceResults.reduce((sum, n) => sum + n.issues.length, 0), + criticalIssues: namespaceResults.reduce( + (sum, n) => sum + n.issues.filter((i) => i.severity === 'critical').length, + 0, + ), + }, + namespaces: namespaceResults, + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error(`Audit failed for ${domainId}: ${message}`); + } + } + + async getDashboard(domainId: string): Promise { + try { + const domain = this.resolveDomain(domainId); + const audit = await this.getAudit(domainId); + const seoReport = await scanSeoPages(domain); + + const auditGate = audit.summary.criticalIssues === 0; + const translationGate = false; // No translations exist yet (0/30 coverage) + const seoGate = seoReport.summary.completenessPercent >= 80; + + const auditScore = this.computeAuditScore(audit); + const translationScore = 0; + const seoScore = seoReport.summary.completenessPercent; + + const weightedScore = Math.round( + auditScore * 0.50 + translationScore * 0.25 + seoScore * 0.25, + ); + + const blockers: string[] = []; + if (!auditGate) { + blockers.push(`${audit.summary.criticalIssues} critical audit issues must be resolved`); + } + if (!translationGate) { + blockers.push('Translation coverage 0% below 50% gate'); + } + if (!seoGate) { + blockers.push(`SEO completeness ${seoReport.summary.completenessPercent}% below 80% gate`); + } + + return { + domain: domainId, + timestamp: new Date().toISOString(), + publishable: auditGate && translationGate && seoGate, + score: weightedScore, + gates: { audit: auditGate, translations: translationGate, seo: seoGate }, + blockers, + auditSummary: audit.summary, + translationSummary: { + totalNamespaces: 0, + fullyTranslated: 0, + untranslated: 0, + totalStale: 0, + coveragePercent: 0, + }, + seoSummary: seoReport.summary, + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error(`Dashboard failed for ${domainId}: ${message}`); + } + } + + private classifyStatus(issues: AuditIssue[]): NamespaceStatus { + if (issues.some((i) => i.severity === 'critical')) return 'BLOCKED'; + if (issues.some((i) => i.severity === 'high')) return 'NEEDS_REVIEW'; + return 'READY'; + } + + private computeAuditScore(report: DomainAuditReport): number { + if (report.summary.totalNamespaces === 0) return 100; + const readyRatio = report.summary.ready / report.summary.totalNamespaces; + const criticalPenalty = Math.min(report.summary.criticalIssues * 10, 50); + const issuePenalty = Math.min(report.summary.totalIssues * 0.5, 30); + return Math.max(0, Math.round(readyRatio * 100 - criticalPenalty - issuePenalty)); + } +}