chore: import DH V2 D5.6.4 production baseline
This commit is contained in:
@@ -0,0 +1,402 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user