import { createHash, randomUUID } from 'node:crypto'; import { mkdir, stat, unlink, writeFile } from 'node:fs/promises'; import { isAbsolute, parse, resolve } from 'node:path'; import { BadRequestException, Injectable, InternalServerErrorException, NotFoundException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { DataSource, EntityManager } from 'typeorm'; import { administrationAuditContext } from '../administration/common/administration-audit'; import { AuditService } from '../audit/audit.service'; import type { AuthPrincipal, RequestWithContext, } from '../common/http/request-context'; import { AssetMediaKind, AssetMediaSource, AssetVersionChangeType, AuditAction, } from '../database/entities'; import { AssetHistoryService } from './asset-history.service'; import { inspectAssetFile, type UploadedAssetFile, } from './asset-media-file'; import type { CreateAssetMediaDto } from './dto/create-asset-media.dto'; import type { UpdateAssetMediaDto } from './dto/update-asset-media.dto'; export interface AssetMediaView { id: string; assetId: string; kind: AssetMediaKind; originalName: string; mimeType: string; sizeBytes: number; sha256: string; title: string | null; description: string | null; capturedAt: Date | null; latitude: number | null; longitude: number | null; accuracyM: number | null; source: AssetMediaSource; uploadedBy: string | null; uploadedByUsername: string | null; createdAt: Date; updatedAt: Date; } interface StoredAssetMedia extends AssetMediaView { storedName: string; } function assetNotFound(): NotFoundException { return new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Activo no encontrado', }); } function mediaNotFound(): NotFoundException { return new NotFoundException({ code: 'ASSET_MEDIA_NOT_FOUND', message: 'Archivo de activo no encontrado', }); } function coordinateError(): BadRequestException { return new BadRequestException({ code: 'INVALID_MEDIA_COORDINATES', message: 'Latitud y longitud deben informarse juntas', }); } @Injectable() export class AssetMediaService { private readonly storageRoot: string; constructor( private readonly dataSource: DataSource, private readonly audit: AuditService, private readonly history: AssetHistoryService, config: ConfigService, ) { const configured = config.get('ASSET_MEDIA_ROOT') ?? '/app/storage/asset-media'; if (!isAbsolute(configured)) { throw new Error('ASSET_MEDIA_ROOT must be an absolute path'); } this.storageRoot = resolve(configured); if (this.storageRoot === parse(this.storageRoot).root) { throw new Error('ASSET_MEDIA_ROOT cannot be the filesystem root'); } } async list(assetId: string): Promise<{ data: AssetMediaView[] }> { await this.requireAsset(this.dataSource.manager, assetId, false); const rows = (await this.dataSource.query( `${this.mediaSelect()} WHERE media.asset_id = $1 AND media.deleted_at IS NULL ORDER BY media.created_at DESC`, [assetId], )) as StoredAssetMedia[]; return { data: rows.map(({ storedName: _storedName, ...media }) => media) }; } async upload( assetId: string, dto: CreateAssetMediaDto, file: UploadedAssetFile | undefined, principal: AuthPrincipal, request: RequestWithContext, ): Promise { this.validateCoordinates(dto.latitude, dto.longitude, dto.accuracyM); const inspected = inspectAssetFile(file, dto.kind); const id = randomUUID(); const storedName = `${id}${inspected.extension}`; const filePath = resolve(this.storageRoot, storedName); const source = principal.transport === 'bearer' ? AssetMediaSource.ANDROID : AssetMediaSource.WEB; const sha256 = createHash('sha256').update(file!.buffer).digest('hex'); await mkdir(this.storageRoot, { recursive: true, mode: 0o700 }); await writeFile(filePath, file!.buffer, { flag: 'wx', mode: 0o600 }); try { return await this.dataSource.transaction(async (manager) => { await this.requireAsset(manager, assetId, true); await manager.query( `INSERT INTO asset_media ( id, asset_id, kind, original_name, stored_name, mime_type, size_bytes, sha256, title, description, captured_at, latitude, longitude, accuracy_m, source, uploaded_by ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16 )`, [ id, assetId, dto.kind, inspected.originalName, storedName, inspected.mimeType, file!.buffer.length, sha256, dto.title?.trim() || null, dto.description?.trim() || null, dto.capturedAt ? new Date(dto.capturedAt) : null, dto.latitude ?? null, dto.longitude ?? null, dto.accuracyM ?? null, source, principal.userId, ], ); const created = await this.loadActive(manager, id); const versionNumber = await this.history.capture( manager, assetId, AssetVersionChangeType.MEDIA_UPLOADED, principal, request, ); await this.audit.record( { ...administrationAuditContext(principal, request), action: AuditAction.ASSET_MEDIA_UPLOADED, entityType: 'asset_media', entityId: id, afterData: this.auditView(created), metadata: { assetId, versionNumber }, }, manager, ); const { storedName: _storedName, ...view } = created; return view; }); } catch (error) { await unlink(filePath).catch(() => undefined); throw error; } } async update( mediaId: string, dto: UpdateAssetMediaDto, principal: AuthPrincipal, request: RequestWithContext, ): Promise { if (Object.keys(dto).length === 0) { throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' }); } return this.dataSource.transaction(async (manager) => { const before = await this.loadActive(manager, mediaId, true); const latitude = dto.latitude === undefined ? before.latitude : dto.latitude; const longitude = dto.longitude === undefined ? before.longitude : dto.longitude; const accuracyM = dto.accuracyM === undefined ? before.accuracyM : dto.accuracyM; this.validateCoordinates(latitude, longitude, accuracyM); await manager.query( `UPDATE asset_media SET title = $2, description = $3, captured_at = $4, latitude = $5, longitude = $6, accuracy_m = $7, updated_at = CURRENT_TIMESTAMP WHERE id = $1 AND deleted_at IS NULL`, [ mediaId, dto.title === undefined ? before.title : dto.title?.trim() || null, dto.description === undefined ? before.description : dto.description?.trim() || null, dto.capturedAt === undefined ? before.capturedAt : dto.capturedAt ? new Date(dto.capturedAt) : null, latitude, longitude, accuracyM, ], ); const updated = await this.loadActive(manager, mediaId); const versionNumber = await this.history.capture( manager, updated.assetId, AssetVersionChangeType.MEDIA_UPDATED, principal, request, ); await this.audit.record( { ...administrationAuditContext(principal, request), action: AuditAction.ASSET_MEDIA_UPDATED, entityType: 'asset_media', entityId: mediaId, beforeData: this.auditView(before), afterData: this.auditView(updated), metadata: { assetId: updated.assetId, versionNumber }, }, manager, ); const { storedName: _storedName, ...view } = updated; return view; }); } async remove( mediaId: string, principal: AuthPrincipal, request: RequestWithContext, ): Promise<{ status: 'removed' }> { return this.dataSource.transaction(async (manager) => { const before = await this.loadActive(manager, mediaId, true); await manager.query( `UPDATE asset_media SET deleted_at = CURRENT_TIMESTAMP, deleted_by = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1 AND deleted_at IS NULL`, [mediaId, principal.userId], ); const versionNumber = await this.history.capture( manager, before.assetId, AssetVersionChangeType.MEDIA_REMOVED, principal, request, ); await this.audit.record( { ...administrationAuditContext(principal, request), action: AuditAction.ASSET_MEDIA_REMOVED, entityType: 'asset_media', entityId: mediaId, beforeData: this.auditView(before), afterData: { active: false }, metadata: { assetId: before.assetId, versionNumber, physicalFileRetained: true }, }, manager, ); return { status: 'removed' }; }); } async content(mediaId: string): Promise<{ filePath: string; media: StoredAssetMedia; }> { const media = await this.loadActive(this.dataSource.manager, mediaId); const filePath = resolve(this.storageRoot, media.storedName); if (!filePath.startsWith(`${this.storageRoot}/`)) { throw new InternalServerErrorException({ code: 'INVALID_MEDIA_STORAGE_PATH', message: 'Ruta de almacenamiento inválida', }); } try { const fileStat = await stat(filePath); if (!fileStat.isFile() || fileStat.size !== media.sizeBytes) throw new Error('size mismatch'); } catch { throw new InternalServerErrorException({ code: 'ASSET_MEDIA_FILE_MISSING', message: 'El archivo físico no está disponible', }); } return { filePath, media }; } private validateCoordinates( latitude: number | null | undefined, longitude: number | null | undefined, accuracyM: number | null | undefined, ): void { const hasLatitude = latitude !== null && latitude !== undefined; const hasLongitude = longitude !== null && longitude !== undefined; if (hasLatitude !== hasLongitude) throw coordinateError(); if (accuracyM !== null && accuracyM !== undefined && !hasLatitude) { throw new BadRequestException({ code: 'MEDIA_ACCURACY_WITHOUT_COORDINATES', message: 'La precisión GPS requiere latitud y longitud', }); } } private async requireAsset( manager: EntityManager, assetId: string, lock: boolean, ): Promise { const suffix = lock ? ' FOR UPDATE' : ''; const [row] = (await manager.query( `SELECT 1 FROM assets WHERE id = $1${suffix}`, [assetId], )) as unknown[]; if (!row) throw assetNotFound(); } private async loadActive( manager: EntityManager, mediaId: string, lock = false, ): Promise { const [row] = (await manager.query( `${this.mediaSelect()} WHERE media.id = $1 AND media.deleted_at IS NULL ${lock ? 'FOR UPDATE OF media' : ''}`, [mediaId], )) as StoredAssetMedia[]; if (!row) throw mediaNotFound(); return row; } private mediaSelect(): string { return `SELECT media.id, media.asset_id AS "assetId", media.kind, media.original_name AS "originalName", media.stored_name AS "storedName", media.mime_type AS "mimeType", media.size_bytes::double precision AS "sizeBytes", media.sha256, media.title, media.description, media.captured_at AS "capturedAt", media.latitude::double precision AS latitude, media.longitude::double precision AS longitude, media.accuracy_m::double precision AS "accuracyM", media.source, media.uploaded_by AS "uploadedBy", uploader.username AS "uploadedByUsername", media.created_at AS "createdAt", media.updated_at AS "updatedAt" FROM asset_media media LEFT JOIN users uploader ON uploader.id = media.uploaded_by`; } private auditView(media: StoredAssetMedia): Record { return { id: media.id, assetId: media.assetId, kind: media.kind, originalName: media.originalName, mimeType: media.mimeType, sizeBytes: media.sizeBytes, sha256: media.sha256, title: media.title, description: media.description, capturedAt: media.capturedAt, latitude: media.latitude, longitude: media.longitude, accuracyM: media.accuracyM, source: media.source, }; } }