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:
parent
774f506822
commit
d81e267658
3 changed files with 225 additions and 0 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue