import { Injectable } from '@nestjs/common';
import { createHash } from 'node:crypto';
import { DatabaseService } from '../../infrastructure/database/database.service';
import { DomainConflictError } from '../errors/domain-conflict.error';
@Injectable()
export class IdempotencyService {
constructor(private readonly database: DatabaseService) {}
hashPayload(payload: unknown): string {
return createHash('sha256')
.update(JSON.stringify(payload ?? null))
.digest('hex');
}
async begin(input: {
organizationId: string;
key: string;
commandName: string;
requestHash: string;
}): Promise<{ replay?: { status: number; body: unknown } }> {
const existing = await this.database.query<{
request_hash: string;
response_status: number | null;
response_body: unknown | null;
completed_at: string | null;
}>(
`select request_hash, response_status, response_body, completed_at
from idempotency_records
where organization_id=$1
and idempotency_key=$2
and command_name=$3
and expires_at>now()
limit 1`,
[input.organizationId, input.key, input.commandName],
);
if (existing[0]) {
if (existing[0].request_hash !== input.requestHash) {
throw new DomainConflictError('idempotency.key_reused_with_different_request');
}
if (existing[0].completed_at && existing[0].response_status != null) {
return {
replay: {
status: existing[0].response_status,
body: existing[0].response_body,
},
};
}
throw new DomainConflictError('idempotency.command_in_progress');
}
await this.database.query(
`insert into idempotency_records(
organization_id, idempotency_key, command_name, request_hash
) values ($1,$2,$3,$4)`,
[input.organizationId, input.key, input.commandName, input.requestHash],
);
return {};
}
async complete(input: {
organizationId: string;
key: string;
commandName: string;
status: number;
body: unknown;
}): Promise<void> {
await this.database.query(
`update idempotency_records
set response_status=$1,
response_body=$2::jsonb,
completed_at=now()
where organization_id=$3
and idempotency_key=$4
and command_name=$5`,
[
input.status,
JSON.stringify(input.body),
input.organizationId,
input.key,
input.commandName,
],
);
}
async abort(input:{
organizationId:string;
key:string;
commandName:string;
requestHash:string;
}):Promise<void> {
await this.database.query(
`delete from idempotency_records
where organization_id=$1
and idempotency_key=$2
and command_name=$3
and request_hash=$4
and completed_at is null`,
[input.organizationId,input.key,input.commandName,input.requestHash],
);
}
}
