Files
dh-inspeccion-v2/api-v3/src/asset-master/asset-media.service.ts
T

403 lines
13 KiB
TypeScript

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<string>('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<AssetMediaView> {
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<AssetMediaView> {
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<void> {
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<StoredAssetMedia> {
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<string, unknown> {
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,
};
}
}