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 {
assertBriefingAcknowledgeInput,
assertBriefingDraftInput,
assertBriefingPublishInput,
} from './briefing-input-validation';
@Injectable()
export class BriefingsService {
constructor(
private readonly database:DatabaseService,
private readonly outbox:OutboxService,
) {}
async upsertDraft(input:{
organizationId:string;
locationId:string;
businessDate:string;
title:string;
body:string;
sourceLocale:string;
createdBy?:string;
}) {
assertBriefingDraftInput(input);
return this.database.transaction(async(tx)=>{
await tx.query(
`select pg_advisory_xact_lock(hashtext($1))`,
[`briefing:${input.locationId}:${input.businessDate}`],
);
const location=await tx.query<{id:string}>(
`select id from locations
where id=$1 and organization_id=$2 and status<>'archived'`,
[input.locationId,input.organizationId],
);
if(!location[0]) throw new DomainConflictError('briefing.location_invalid');
const existing=await tx.query<{id:string;status:string}>(
`select id,status
from daily_briefings
where organization_id=$1 and location_id=$2 and business_date=$3::date
for update`,
[input.organizationId,input.locationId,input.businessDate],
);
if(existing[0]?.status==='published') {
throw new DomainConflictError('briefing.published_immutable');
}
if(existing[0]) {
await tx.query(
`update daily_briefings
set title=$1,body=$2,source_locale=$3
where id=$4 and organization_id=$5`,
[
input.title,input.body,input.sourceLocale,
existing[0].id,input.organizationId,
],
);
return {id:existing[0].id};
}
const id=randomUUID();
await tx.query(
`insert into daily_briefings(
id,organization_id,location_id,business_date,
source_locale,title,body,created_by
) values($1,$2,$3,$4::date,$5,$6,$7,$8)`,
[
id,input.organizationId,input.locationId,input.businessDate,
input.sourceLocale,input.title,input.body,input.createdBy??null,
],
);
return {id};
},{lockTimeoutMs:3000,statementTimeoutMs:10000});
}
async publish(organizationId:string,briefingId:string) {
assertBriefingPublishInput(organizationId, briefingId);
return this.database.transaction(async(tx)=>{
const rows=await tx.query<{id:string;location_id:string;business_date:string}>(
`update daily_briefings
set status='published',published_at=now()
where id=$1 and organization_id=$2 and status='draft'
returning id,location_id,business_date::text`,
[briefingId,organizationId],
);
if(!rows[0]) throw new DomainConflictError('briefing.not_publishable');
await this.outbox.append({
organizationId,
aggregateType:'DailyBriefing',
aggregateId:briefingId,
eventType:'DailyBriefingPublished',
payload:{
briefingId,
locationId:rows[0].location_id,
businessDate:rows[0].business_date,
},
},tx);
return {id:briefingId,status:'published'};
});
}
async acknowledge(input:{
organizationId:string;
briefingId:string;
employeeId:string;
localeShown:string;
}) {
assertBriefingAcknowledgeInput(input);
return this.database.transaction(async(tx)=>{
const valid=await tx.query<{valid:boolean}>(
`select exists(
select 1
from daily_briefings db
join employees e on e.id=$3
where db.id=$1
and db.organization_id=$2
and db.status='published'
and e.organization_id=$2
and e.status='active'
) as valid`,
[input.briefingId,input.organizationId,input.employeeId],
);
if(!valid[0]?.valid) {
throw new DomainConflictError('briefing.acknowledge_forbidden');
}
await tx.query(
`insert into daily_briefing_acknowledgements(
briefing_id,employee_id,locale_shown
) values($1,$2,$3)
on conflict do nothing`,
[input.briefingId,input.employeeId,input.localeShown],
);
await this.outbox.append({
organizationId:input.organizationId,
aggregateType:'DailyBriefing',
aggregateId:input.briefingId,
eventType:'DailyBriefingAcknowledged',
payload:{
briefingId:input.briefingId,
employeeId:input.employeeId,
localeShown:input.localeShown,
},
},tx);
return {acknowledged:true};
});
}
}
