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 { AuditService } from '../../audit/audit.service';
import { ObjectStorageProvider } from '../providers/object-storage.provider';
import { FilePolicyService } from './file-policy.service';
import { FileAuthorizationService } from './file-authorization.service';
import {
assertFileAccessInput,
assertFileCompleteInput,
assertFileUploadSessionInput,
} from './file-input-validation';
@Injectable()
export class FileService {
constructor(
private readonly database:DatabaseService,
private readonly storage:ObjectStorageProvider,
private readonly policy:FilePolicyService,
private readonly authorization:FileAuthorizationService,
private readonly outbox:OutboxService,
private readonly audit:AuditService,
) {}
async createUploadSession(input:unknown) {
assertFileUploadSessionInput(input);
this.policy.validate({
purpose: input.purpose,
sizeBytes: input.sizeBytes,
mimeType: input.mimeType,
fileName: input.fileName,
});
const assetId=randomUUID();
const sessionId=randomUUID();
const storageKey=`org/${input.organizationId}/${randomUUID()}`;
// DB state is created atomically before the external signed URL is generated.
await this.database.transaction(async(tx)=>{
await tx.query(
`insert into file_assets(
id,organization_id,location_id,owner_entity_type,owner_entity_id,purpose,
storage_key,original_file_name,mime_type,size_bytes,checksum_sha256,status,created_by_user_id
) values($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'uploading',$12)`,
[
assetId,input.organizationId,input.locationId??null,input.ownerEntityType??null,
input.ownerEntityId??null,input.purpose,storageKey,input.fileName,input.mimeType,
input.sizeBytes,input.checksumSha256??null,input.userId??null,
],
);
await tx.query(
`insert into file_upload_sessions(
id,organization_id,location_id,file_asset_id,expected_size_bytes,
expected_mime_type,expected_checksum_sha256,expires_at
) values($1,$2,$3,$4,$5,$6,$7,now()+interval '15 minutes')`,
[
sessionId,input.organizationId,input.locationId??null,assetId,input.sizeBytes,
input.mimeType,input.checksumSha256??null,
],
);
});
try {
return {
uploadSessionId:sessionId,
fileAssetId:assetId,
method:'PUT',
uploadUrl:await this.storage.signedUpload({
key:storageKey,
contentType:input.mimeType,
}),
expiresInSeconds:900,
};
} catch {
await this.database.transaction(async(tx)=>{
await tx.query(
`update file_assets
set status='rejected',rejection_reason='signed_upload_unavailable',updated_at=now()
where id=$1 and organization_id=$2 and status='uploading'`,
[assetId,input.organizationId],
);
await this.outbox.append({
organizationId:input.organizationId,
aggregateType:'FileAsset',
aggregateId:assetId,
eventType:'FileUploadSessionFailed',
payload:{fileAssetId:assetId,reason:'signed_upload_unavailable'},
},tx);
});
throw new DomainConflictError('files.upload_provider_unavailable');
}
}
async complete(input:unknown) {
assertFileCompleteInput(input);
// External HEAD intentionally occurs outside the DB transaction.
const rows=await this.database.query<any>(
`select fus.*,fa.storage_key,fa.mime_type,fa.size_bytes,fa.status,fa.location_id
from file_upload_sessions fus
join file_assets fa on fa.id=fus.file_asset_id
where fus.id=$1 and fus.organization_id=$2
and ($3::uuid is null or fus.location_id=$3)
and fus.completed_at is null and fus.expires_at>now()
and fa.status='uploading'`,
[input.uploadSessionId,input.organizationId,input.locationId??null],
);
const row=rows[0];
if(!row) throw new DomainConflictError('files.upload_session_invalid');
let head:any;
try {
head=await this.storage.head(row.storage_key);
} catch {
throw new DomainConflictError('files.object_missing');
}
const actualSize=Number(head.ContentLength??-1);
const expectedSize=Number(row.expected_size_bytes);
const actualType=head.ContentType??row.mime_type;
const matches=
Number.isSafeInteger(actualSize) &&
Number.isSafeInteger(expectedSize) &&
actualSize===expectedSize &&
actualType===row.expected_mime_type;
return this.database.transaction(async(tx)=>{
const locked=await tx.query<any>(
`select fus.id,fa.id as file_asset_id,fa.status
from file_upload_sessions fus
join file_assets fa on fa.id=fus.file_asset_id
where fus.id=$1 and fus.organization_id=$2
and ($3::uuid is null or fus.location_id=$3)
and fus.completed_at is null and fus.expires_at>now()
for update of fus,fa`,
[input.uploadSessionId,input.organizationId,input.locationId??null],
);
if(!locked[0]||locked[0].status!=='uploading') {
throw new DomainConflictError('files.upload_session_invalid');
}
if(!matches) {
await tx.query(
`update file_assets
set status='rejected',rejection_reason='metadata_mismatch',updated_at=now()
where id=$1 and organization_id=$2`,
[row.file_asset_id,input.organizationId],
);
await tx.query(
`update file_upload_sessions set completed_at=now() where id=$1`,
[input.uploadSessionId],
);
await this.outbox.append({
organizationId:input.organizationId,
aggregateType:'FileAsset',
aggregateId:row.file_asset_id,
eventType:'FileRejected',
payload:{fileAssetId:row.file_asset_id,reason:'metadata_mismatch'},
},tx);
throw new DomainConflictError('files.upload_metadata_mismatch');
}
await tx.query(
`update file_assets
set status='processing',scan_status='pending',updated_at=now()
where id=$1 and organization_id=$2 and status='uploading'`,
[row.file_asset_id,input.organizationId],
);
await tx.query(
`update file_upload_sessions
set completed_at=now()
where id=$1 and organization_id=$2 and completed_at is null`,
[input.uploadSessionId,input.organizationId],
);
await this.outbox.append({
organizationId:input.organizationId,
aggregateType:'FileAsset',
aggregateId:row.file_asset_id,
eventType:'FileUploaded',
payload:{fileAssetId:row.file_asset_id,locationId:row.location_id??null},
},tx);
return {fileAssetId:row.file_asset_id,status:'processing'};
},{lockTimeoutMs:3000,statementTimeoutMs:10000});
}
async get(input:unknown) {
assertFileAccessInput(input);
const rows=await this.database.query<any>(
`select id,location_id,owner_entity_type,owner_entity_id,purpose,
original_file_name,mime_type,size_bytes,status,created_at,updated_at
from file_assets
where id=$1 and organization_id=$2 and status<>'deleted'`,
[input.fileAssetId,input.organizationId],
);
if(!rows[0]) throw new DomainConflictError('files.not_found');
await this.authorization.assertRead({
organizationId:input.organizationId,
locationId:input.locationId,
userId:input.userId,
asset:rows[0],
});
return rows[0];
}
async downloadLink(input:unknown) {
assertFileAccessInput(input);
const rows=await this.database.query<any>(
`select * from file_assets
where id=$1 and organization_id=$2 and status='available'`,
[input.fileAssetId,input.organizationId],
);
const row=rows[0];
if(!row) throw new DomainConflictError('files.not_available');
await this.authorization.assertRead({
organizationId:input.organizationId,
locationId:input.locationId,
userId:input.userId,
asset:row,
});
if(row.scan_status!=='clean'&&row.scan_status!=='not_required') {
throw new DomainConflictError('files.scan_not_clean');
}
if(['employee_document','invoice_document','export_result'].includes(row.purpose)) {
await this.audit.record({
organizationId:input.organizationId,
actorUserId:input.userId,
eventType:'SensitiveFileDownloadRequested',
entityType:'FileAsset',
entityId:input.fileAssetId,
metadata:{purpose:row.purpose},
});
}
return {
url:await this.storage.signedDownload({
key:row.storage_key,
fileName:row.original_file_name,
expiresSeconds:300,
}),
expiresInSeconds:300,
};
}
async archive(input:unknown) {
assertFileAccessInput(input);
const assets=await this.database.query<any>(
`select *
from file_assets
where id=$1 and organization_id=$2 and status in ('available','rejected')`,
[input.fileAssetId,input.organizationId],
);
const asset=assets[0];
if(!asset) throw new DomainConflictError('files.archive_invalid');
await this.authorization.assertRead({
organizationId:input.organizationId,
locationId:input.locationId,
userId:input.userId,
asset,
});
const rows=await this.database.query<any>(
`update file_assets
set status='archived',archived_at=now(),updated_at=now()
where id=$1 and organization_id=$2 and status in ('available','rejected')
returning id,status`,
[input.fileAssetId,input.organizationId],
);
if(!rows[0]) throw new DomainConflictError('files.archive_invalid');
await this.audit.record({
organizationId:input.organizationId,
actorUserId:input.userId,
eventType:'FileArchived',
entityType:'FileAsset',
entityId:input.fileAssetId,
}).catch(()=>undefined);
return rows[0];
}
}
