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 { AuditService } from '../../audit/audit.service';
import { FileService } from '../../files/application/file.service';
import {
assertExportEnum,
assertExportRecord,
assertExportScope,
assertExportText,
assertOptionalExportText,
} from './exports-input-validation';
@Injectable()
export class ExportService {
constructor(
private readonly database:DatabaseService,
private readonly audit:AuditService,
private readonly files:FileService,
) {}
async create(input:unknown) {
assertExportScope(input, 'exports.input_invalid', { locationRequired: false });
assertExportText(input.exportType, 100, 'exports.input_invalid');
assertExportText(input.format, 50, 'exports.input_invalid');
assertExportEnum(input.exportType, ['sales_report', 'inventory', 'waste', 'purchasing', 'employees', 'guests', 'safety', 'incidents', 'audit'], 'exports.input_invalid');
assertExportEnum(input.format, ['csv', 'xlsx', 'pdf'], 'exports.input_invalid');
const organizationId = input.organizationId as string;
const exportType = input.exportType as string;
const format = input.format as string;
const userId = input.userId == null ? undefined : input.userId as string;
const filters = input.filters ?? input.parameters ?? {};
assertExportRecord(filters, 'exports.input_invalid');
assertOptionalExportText(input.userId, 200, 'exports.input_invalid');
const id=randomUUID();
await this.database.query(
`insert into export_jobs(
id,organization_id,location_id,export_type,format,filters_json,
requested_by_user_id,status
) values($1,$2,$3,$4,$5,$6::jsonb,$7,'queued')`,
[
id,organizationId,input.locationId??null,exportType,format,
JSON.stringify(filters),userId??null,
],
);
if(['employees','guests','audit'].includes(exportType)) {
await this.audit.record({
organizationId,
actorUserId:userId,
eventType:'SensitiveExportRequested',
entityType:'ExportJob',
entityId:id,
metadata:{exportType,format},
});
}
return {id,status:'queued'};
}
async get(input:{organizationId:string;locationId:string;id:string}) {
const rows=await this.database.query<any>(
`select * from export_jobs where id=$1 and organization_id=$2 and location_id=$3`,
[input.id,input.organizationId,input.locationId],
);
if(!rows[0]) throw new DomainConflictError('exports.not_found');
return rows[0];
}
async cancel(input:{organizationId:string;locationId:string;id:string}) {
const rows=await this.database.query<any>(
`update export_jobs set status='cancelled'
where id=$1 and organization_id=$2 and location_id=$3 and status='queued'
returning id,status`,
[input.id,input.organizationId,input.locationId],
);
if(!rows[0]) throw new DomainConflictError('exports.cancel_invalid');
return rows[0];
}
async downloadLink(input:{organizationId:string;locationId:string;id:string;userId:string}) {
const job=await this.get({organizationId:input.organizationId,locationId:input.locationId,id:input.id});
if(job.status!=='completed' || !job.file_asset_id) {
throw new DomainConflictError('exports.not_ready');
}
await this.audit.record({
organizationId:input.organizationId,
actorUserId:input.userId,
eventType:'ExportDownloadRequested',
entityType:'ExportJob',
entityId:input.id,
metadata:{exportType:job.export_type},
});
return this.files.downloadLink({
organizationId:input.organizationId,
locationId:input.locationId,
fileAssetId:job.file_asset_id,
userId:input.userId,
});
}
}
