feat(content-lifecycle): Add lifecycle state definitions, API endpoints, and transition logic for managing content states like publish, archive, and moderation

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-25 23:56:31 -07:00
parent 774f506822
commit d81e267658
3 changed files with 225 additions and 0 deletions

View file

@ -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<unknown> {
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<unknown> {
return this.lifecycleService.getAudit(domain);
}
}

View file

@ -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 {}

View file

@ -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<DomainAuditReport> {
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<NamespaceStatus, number> = { 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<PublishDecision> {
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));
}
}