import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { DatabaseService } from '../../../infrastructure/database/database.service';
import { DomainConflictError } from '../../../common/errors/domain-conflict.error';
import { OutboxService } from '../../../infrastructure/outbox/outbox.service';
import {
assertKnowledgeAcknowledgeInput,
assertKnowledgeDraftInput,
assertKnowledgePublishInput,
} from './knowledge-input-validation';
@Injectable()
export class KnowledgeService {
constructor(
private readonly database:DatabaseService,
private readonly outbox:OutboxService,
) {}
async createDraft(input:{
organizationId:string;
slug:string;
title:string;
bodyMarkdown:string;
articleType?:string;
sourceLocale?:string;
createdBy?:string;
}) {
assertKnowledgeDraftInput(input);
return this.database.transaction(async(tx)=>{
const articleId=randomUUID();
const versionId=randomUUID();
await tx.query(
`insert into knowledge_articles(
id,organization_id,slug,title,article_type
) values($1,$2,$3,$4,$5)`,
[
articleId,input.organizationId,input.slug,input.title,
input.articleType??'sop',
],
);
await tx.query(
`insert into knowledge_article_versions(
id,article_id,version_number,status,source_locale,title,body_markdown,created_by
) values($1,$2,1,'draft',$3,$4,$5,$6)`,
[
versionId,articleId,input.sourceLocale??'de-DE',
input.title,input.bodyMarkdown,input.createdBy??null,
],
);
await this.outbox.append({
organizationId:input.organizationId,
aggregateType:'KnowledgeArticle',
aggregateId:articleId,
eventType:'KnowledgeArticleDraftCreated',
payload:{articleId,articleVersionId:versionId},
},tx);
return {articleId,versionId};
},{lockTimeoutMs:3000,statementTimeoutMs:10000});
}
async publish(organizationId:string,versionId:string) {
assertKnowledgePublishInput(organizationId, versionId);
return this.database.transaction(async(tx)=>{
const rows=await tx.query<{article_id:string;status:string}>(
`select kav.article_id,kav.status
from knowledge_article_versions kav
join knowledge_articles ka on ka.id=kav.article_id
where kav.id=$1 and ka.organization_id=$2
for update of kav`,
[versionId,organizationId],
);
if(!rows[0]||rows[0].status!=='draft') {
throw new DomainConflictError('knowledge.version_not_publishable');
}
await tx.query(
`select id
from knowledge_article_versions
where article_id=$1
order by version_number
for update`,
[rows[0].article_id],
);
await tx.query(
`update knowledge_article_versions
set status='retired'
where article_id=$1 and status='published'`,
[rows[0].article_id],
);
const published=await tx.query<{id:string}>(
`update knowledge_article_versions
set status='published',published_at=now()
where id=$1 and status='draft'
returning id`,
[versionId],
);
if(!published[0]) throw new DomainConflictError('knowledge.version_not_publishable');
await this.outbox.append({
organizationId,
aggregateType:'KnowledgeArticle',
aggregateId:rows[0].article_id,
eventType:'KnowledgeArticlePublished',
payload:{articleId:rows[0].article_id,articleVersionId:versionId},
},tx);
return {articleId:rows[0].article_id,versionId,status:'published'};
},{lockTimeoutMs:3000,statementTimeoutMs:10000});
}
async acknowledge(input:{
organizationId:string;
articleVersionId:string;
employeeId:string;
localeShown:string;
}) {
assertKnowledgeAcknowledgeInput(input);
return this.database.transaction(async(tx)=>{
const valid=await tx.query<{valid:boolean}>(
`select exists(
select 1
from knowledge_article_versions kav
join knowledge_articles ka on ka.id=kav.article_id
join employees e on e.id=$3
where kav.id=$1
and ka.organization_id=$2
and e.organization_id=$2
and e.status='active'
and kav.status='published'
) as valid`,
[input.articleVersionId,input.organizationId,input.employeeId],
);
if(!valid[0]?.valid) {
throw new DomainConflictError('knowledge.acknowledge_forbidden');
}
await tx.query(
`insert into knowledge_article_acknowledgements(
article_version_id,employee_id,locale_shown
) values($1,$2,$3)
on conflict(article_version_id,employee_id) do nothing`,
[input.articleVersionId,input.employeeId,input.localeShown],
);
await this.outbox.append({
organizationId:input.organizationId,
aggregateType:'KnowledgeArticleVersion',
aggregateId:input.articleVersionId,
eventType:'KnowledgeArticleAcknowledged',
payload:{
articleVersionId:input.articleVersionId,
employeeId:input.employeeId,
localeShown:input.localeShown,
},
},tx);
return {acknowledged:true};
});
}
}
