403 lines
13 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|