feat(f6.8): harden offline field flow and act documents
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 3m41s
DH V2 CI / API · typecheck, tests, build (push) Successful in 31s
DH V2 CI / WEB · typecheck, build (push) Successful in 19s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / Docker / scripts contract (push) Successful in 1m14s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 3m41s
DH V2 CI / API · typecheck, tests, build (push) Successful in 31s
DH V2 CI / WEB · typecheck, build (push) Successful in 19s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / Docker / scripts contract (push) Successful in 1m14s
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "dhv2-api",
|
||||
"version": "0.29.0-7",
|
||||
"version": "0.29.0-8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dhv2-api",
|
||||
"version": "0.29.0-7",
|
||||
"version": "0.29.0-8",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dhv2-api",
|
||||
"version": "0.29.0-7",
|
||||
"version": "0.29.0-8",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
|
||||
@@ -260,7 +260,7 @@ export const MASTER_BOOTSTRAP_TYPES: MasterBootstrapTypePreset[] = [
|
||||
{
|
||||
code: 'equipo',
|
||||
name: 'Equipo',
|
||||
description: 'Equipo físico genérico. Se conserva como alternativa cuando el inventario no permita clasificarlo todavía en una familia técnica específica.',
|
||||
description: 'Equipo físico genérico. Se conserva como alternativa cuando el inventario no permita clasificarlo todavía en un tipo de instalación/subinstalación específico.',
|
||||
canBeRoot: false,
|
||||
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||
allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion', 'subestacion', 'pozo', 'zona_bombas', 'sistema_defensa_incendios', 'cargadero_descargadero'],
|
||||
|
||||
@@ -756,6 +756,7 @@ export class AssetsService {
|
||||
const definitions = await this.loadDefinitions(manager, type.id);
|
||||
const values = validateAssetAttributeValues(definitions, dto.attributes);
|
||||
const asset = manager.getRepository(Asset).create({
|
||||
id: dto.clientGeneratedId,
|
||||
assetTypeId: type.id,
|
||||
parentId: dto.parentId,
|
||||
operationalAreaId: dto.operationalAreaId,
|
||||
|
||||
@@ -2,6 +2,10 @@ import { Transform } from 'class-transformer';
|
||||
import { IsObject, IsOptional, IsString, IsUUID, Matches, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class CreateFieldDiscoveryDto {
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
clientGeneratedId?: string;
|
||||
|
||||
@IsUUID('4')
|
||||
visitId!: string;
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ export class InventoryFamilyCatalogService {
|
||||
) VALUES ($1,$2,$3,NULL,$4::jsonb,'MANUAL:F6',true)
|
||||
RETURNING id
|
||||
`,[code,dto.name,dto.level,JSON.stringify(this.cleanLabels(dto.informationLabels ?? []))])) as Array<{id:string}>;
|
||||
if (!inserted) throw new Error('No se pudo crear la clasificación de Inventario');
|
||||
if (!inserted) throw new Error('No se pudo crear el tipo de Inventario');
|
||||
await this.replaceParents(manager, inserted.id, parentIds);
|
||||
const created = await this.family(inserted.id,false,manager);
|
||||
await this.audit.record({
|
||||
@@ -186,7 +186,7 @@ export class InventoryFamilyCatalogService {
|
||||
} catch (error) {
|
||||
if (this.isUniqueViolation(error)) throw new ConflictException({
|
||||
code:'INVENTORY_FAMILY_ATTRIBUTE_CODE_EXISTS',
|
||||
message:'Ya existe un campo técnico con ese código en la clasificación',
|
||||
message:'Ya existe un campo técnico con ese código en el tipo',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
@@ -328,7 +328,7 @@ export class InventoryFamilyCatalogService {
|
||||
${lockClause}
|
||||
`),[familyId])) as FamilyRow[];
|
||||
if (!rows[0]) throw new NotFoundException({
|
||||
code:'INVENTORY_FAMILY_NOT_FOUND',message:'La clasificación de Inventario no existe',
|
||||
code:'INVENTORY_FAMILY_NOT_FOUND',message:'El tipo de Inventario no existe',
|
||||
});
|
||||
return rows[0];
|
||||
}
|
||||
@@ -343,7 +343,7 @@ export class InventoryFamilyCatalogService {
|
||||
if (level==='INSTALLATION') {
|
||||
if (ids.length) throw new BadRequestException({
|
||||
code:'INVENTORY_FAMILY_PARENT_NOT_ALLOWED',
|
||||
message:'Una clasificación de Instalación no lleva compatibilidades padre',
|
||||
message:'Un tipo de Instalación no lleva compatibilidades padre',
|
||||
});
|
||||
return [];
|
||||
}
|
||||
@@ -352,7 +352,7 @@ export class InventoryFamilyCatalogService {
|
||||
message:'Elegí al menos un tipo de Instalación compatible con esta Subinstalación',
|
||||
});
|
||||
if (ownId && ids.includes(ownId)) throw new BadRequestException({
|
||||
code:'INVENTORY_FAMILY_PARENT_CYCLE',message:'Una clasificación no puede ser compatible consigo misma',
|
||||
code:'INVENTORY_FAMILY_PARENT_CYCLE',message:'Un tipo no puede ser compatible consigo mismo',
|
||||
});
|
||||
const [count]=(await manager.query(`
|
||||
SELECT COUNT(*)::integer AS total FROM inventory_families
|
||||
@@ -434,7 +434,7 @@ export class InventoryFamilyCatalogService {
|
||||
if (!unique.has(identity)) unique.set(identity,clean);
|
||||
}
|
||||
if (unique.size>100) throw new ConflictException({
|
||||
code:'INVENTORY_FAMILY_TOO_MANY_FIELDS',message:'La clasificación admite hasta 100 campos de información',
|
||||
code:'INVENTORY_FAMILY_TOO_MANY_FIELDS',message:'El tipo admite hasta 100 campos de información',
|
||||
});
|
||||
return [...unique.values()];
|
||||
}
|
||||
|
||||
@@ -253,7 +253,7 @@ export class InventoryStructureService {
|
||||
dto.kind === 'EMPRESA' ? 'Maestro manual de Empresas' : 'Estructura manual de Inventario',
|
||||
dto.kind === 'EMPRESA' ? 'inventory-master:empresa' : `inventory-structure:${dto.kind.toLowerCase()}`,
|
||||
family
|
||||
? `Clasificación técnica: ${family.code} · ${family.name}`
|
||||
? `Tipo: ${family.code} · ${family.name}`
|
||||
: yacimientoContext
|
||||
? `Tipo de concesión: ${yacimientoContext.concessionName}`
|
||||
: null,
|
||||
@@ -458,13 +458,13 @@ export class InventoryStructureService {
|
||||
if (!expectedLevel) {
|
||||
if (familyId) throw new BadRequestException({
|
||||
code: 'INVENTORY_STRUCTURE_FAMILY_NOT_ALLOWED',
|
||||
message: 'Empresa, Departamento, Área y Yacimiento no llevan clasificación técnica',
|
||||
message: 'Empresa, Departamento, Área y Yacimiento no llevan Tipo de instalación/subinstalación',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
if (!familyId) throw new BadRequestException({
|
||||
code: 'INVENTORY_STRUCTURE_FAMILY_REQUIRED',
|
||||
message: `Elegí la clasificación técnica de la ${kind.toLowerCase()}`,
|
||||
message: `Elegí el tipo de la ${kind.toLowerCase()}`,
|
||||
});
|
||||
const rows = (await manager.query(`
|
||||
SELECT family.id,family.code,family.name,family.level,
|
||||
@@ -479,15 +479,15 @@ export class InventoryStructureService {
|
||||
LIMIT 1
|
||||
`, [familyId])) as FamilyRow[];
|
||||
const family = rows[0];
|
||||
if (!family) throw new NotFoundException({ code: 'INVENTORY_FAMILY_NOT_FOUND', message: 'La clasificación técnica no existe' });
|
||||
if (!family) throw new NotFoundException({ code: 'INVENTORY_FAMILY_NOT_FOUND', message: 'El Tipo de instalación/subinstalación no existe' });
|
||||
if (family.level !== expectedLevel) throw new BadRequestException({
|
||||
code: 'INVENTORY_FAMILY_LEVEL_INVALID',
|
||||
message: 'La clasificación técnica no corresponde al nivel seleccionado',
|
||||
message: 'El Tipo de instalación/subinstalación no corresponde al nivel seleccionado',
|
||||
});
|
||||
if (kind === 'SUBINSTALACION') {
|
||||
if (!parent?.inventoryFamilyId) throw new BadRequestException({
|
||||
code:'INVENTORY_PARENT_FAMILY_REQUIRED',
|
||||
message:'La Instalación padre debe tener una clasificación técnica válida',
|
||||
message:'La Instalación padre debe tener un Tipo de instalación válido',
|
||||
});
|
||||
const [compatible]=(await manager.query(`
|
||||
SELECT 1 AS ok FROM inventory_family_parent_rules
|
||||
@@ -496,7 +496,7 @@ export class InventoryStructureService {
|
||||
`,[family.id,parent.inventoryFamilyId])) as Array<{ok:number}>;
|
||||
if (!compatible) throw new BadRequestException({
|
||||
code: 'INVENTORY_SUBINSTALLATION_FAMILY_PARENT_INVALID',
|
||||
message: 'Ese tipo de Subinstalación no es compatible con la clasificación de la Instalación seleccionada',
|
||||
message: 'Ese tipo de Subinstalación no es compatible con el tipo de la Instalación seleccionada',
|
||||
});
|
||||
}
|
||||
return family;
|
||||
|
||||
@@ -29,7 +29,7 @@ export class InventoryTechnicalValuesService {
|
||||
for (const [definitionId,raw] of Object.entries(dto.values)) {
|
||||
const definition=definitionById.get(definitionId);
|
||||
if (!definition || !definition.isActive) throw new BadRequestException({
|
||||
code:'INVENTORY_TECHNICAL_FIELD_INVALID',message:'Uno o más campos técnicos no pertenecen a la clasificación actual',
|
||||
code:'INVENTORY_TECHNICAL_FIELD_INVALID',message:'Uno o más campos técnicos no pertenecen al tipo actual',
|
||||
});
|
||||
const value=this.normalize(definition,raw);
|
||||
if (value!==undefined) normalized[definitionId]=value;
|
||||
@@ -67,7 +67,7 @@ export class InventoryTechnicalValuesService {
|
||||
const asset=rows[0];
|
||||
if (!asset) throw new NotFoundException({code:'ASSET_NOT_FOUND',message:'El registro de Inventario no existe'});
|
||||
if (!asset.inventoryFamilyId || !asset.familyCode || !asset.familyName || !asset.familyLevel) throw new BadRequestException({
|
||||
code:'INVENTORY_TECHNICAL_FAMILY_REQUIRED',message:'Este nivel no tiene clasificación técnica y no admite campos técnicos por rubro',
|
||||
code:'INVENTORY_TECHNICAL_FAMILY_REQUIRED',message:'Este nivel no tiene Tipo de instalación/subinstalación y no admite campos técnicos por rubro',
|
||||
});
|
||||
const definitions=(await manager.query(`
|
||||
SELECT id,code,name,data_type AS "dataType",is_required AS "isRequired",is_active AS "isActive",
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class F68OfflineEvidenceIdempotency1790128200000 implements MigrationInterface {
|
||||
name = 'F68OfflineEvidenceIdempotency1790128200000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE inspection_finding_evidence
|
||||
ADD COLUMN client_operation_id uuid
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX uq_inspection_finding_evidence_client_operation
|
||||
ON inspection_finding_evidence (client_operation_id)
|
||||
WHERE client_operation_id IS NOT NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE asset_field_capture_events
|
||||
ADD COLUMN client_operation_id uuid
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX uq_asset_field_capture_events_client_operation
|
||||
ON asset_field_capture_events (client_operation_id)
|
||||
WHERE client_operation_id IS NOT NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query('DROP INDEX uq_asset_field_capture_events_client_operation');
|
||||
await queryRunner.query('ALTER TABLE asset_field_capture_events DROP COLUMN client_operation_id');
|
||||
await queryRunner.query('DROP INDEX uq_inspection_finding_evidence_client_operation');
|
||||
await queryRunner.query('ALTER TABLE inspection_finding_evidence DROP COLUMN client_operation_id');
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,10 @@ import {
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateInspectionActDto {
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
clientGeneratedId?: string;
|
||||
|
||||
@IsISO8601({ strict: true })
|
||||
occurredAt!: string;
|
||||
|
||||
|
||||
@@ -53,6 +53,12 @@ export class InspectionActsController {
|
||||
return this.acts.listGlobal(query);
|
||||
}
|
||||
|
||||
@Get(':id/field-media')
|
||||
@RequirePermissions('inspection_acts.read', 'assets.read_media')
|
||||
fieldMedia(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
|
||||
return this.acts.listFieldMedia(id);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermissions('inspection_acts.read')
|
||||
get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
|
||||
|
||||
@@ -278,6 +278,48 @@ export class InspectionActsService {
|
||||
return this.dataSource.transaction((manager) => this.loadView(manager, id));
|
||||
}
|
||||
|
||||
async listFieldMedia(id: string) {
|
||||
const [act] = await this.dataSource.query(
|
||||
'SELECT id FROM inspection_acts WHERE id=$1::uuid',
|
||||
[id],
|
||||
) as Array<{ id: string }>;
|
||||
if (!act) throw actNotFound();
|
||||
const data = await this.dataSource.query(`
|
||||
SELECT
|
||||
media.id,
|
||||
media.asset_id AS "assetId",
|
||||
asset.code AS "assetCode",
|
||||
asset.name AS "assetName",
|
||||
media.kind,
|
||||
media.original_name AS "originalName",
|
||||
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,
|
||||
capture.device_captured_at AS "fieldCapturedAt",
|
||||
capture.created_at AS "createdAt"
|
||||
FROM inspection_acts act
|
||||
INNER JOIN inspection_act_assets link
|
||||
ON link.act_id=act.id AND link.included=true
|
||||
INNER JOIN asset_field_capture_events capture
|
||||
ON capture.visit_id=act.visit_id
|
||||
AND capture.asset_id=link.asset_id
|
||||
AND capture.event_type='PHOTO'
|
||||
INNER JOIN asset_media media
|
||||
ON media.id=capture.media_id AND media.deleted_at IS NULL
|
||||
INNER JOIN assets asset ON asset.id=media.asset_id
|
||||
WHERE act.id=$1::uuid
|
||||
ORDER BY capture.device_captured_at DESC,capture.created_at DESC,media.id
|
||||
`, [id]);
|
||||
return { data };
|
||||
}
|
||||
|
||||
async create(
|
||||
visitId: string,
|
||||
dto: CreateInspectionActDto,
|
||||
@@ -289,6 +331,22 @@ export class InspectionActsService {
|
||||
const visit = await this.lockVisit(manager, visitId);
|
||||
this.assertVisitOpen(visit);
|
||||
await this.assertActorAssigned(manager, visitId, principal);
|
||||
if (dto.clientGeneratedId) {
|
||||
const [existing] = await manager.query(`
|
||||
SELECT id, visit_id AS "visitId"
|
||||
FROM inspection_acts
|
||||
WHERE id=$1
|
||||
`, [dto.clientGeneratedId]) as Array<{ id: string; visitId: string }>;
|
||||
if (existing) {
|
||||
if (existing.visitId !== visitId) {
|
||||
throw new ConflictException({
|
||||
code: 'INSPECTION_ACT_CLIENT_ID_REUSED',
|
||||
message: 'El identificador offline del Acta ya fue utilizado en otra inspección',
|
||||
});
|
||||
}
|
||||
return this.loadView(manager, existing.id);
|
||||
}
|
||||
}
|
||||
await this.assertVisitHasNoDraftAct(manager, visitId);
|
||||
await this.assertVisitAssets(manager, visitId, dto.assetIds);
|
||||
const occurredAt = new Date(dto.occurredAt);
|
||||
@@ -305,6 +363,7 @@ export class InspectionActsService {
|
||||
`, [occurredAt])) as Array<{ date_part: string }>;
|
||||
const code = `ACT-${String(actNumber).padStart(5, '0')}-${dateRow.date_part}`;
|
||||
const act = manager.getRepository(InspectionAct).create({
|
||||
id: dto.clientGeneratedId,
|
||||
visitId,
|
||||
actYear,
|
||||
actNumber,
|
||||
|
||||
@@ -16,6 +16,10 @@ import {
|
||||
} from '../../database/entities';
|
||||
|
||||
export class CreateInspectionEvidenceDto {
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
operationId?: string;
|
||||
|
||||
@IsEnum(InspectionEvidenceKind)
|
||||
kind!: InspectionEvidenceKind;
|
||||
|
||||
|
||||
@@ -16,6 +16,10 @@ const optionalText = ({ value }: { value: unknown }) =>
|
||||
typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
|
||||
export class CreateInspectionFindingDto {
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
clientGeneratedId?: string;
|
||||
|
||||
@IsUUID('4')
|
||||
assetId!: string;
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ export class F3FindingCatalogResolverService {
|
||||
inventoryFamily: null,
|
||||
catalogSource: 'INVENTORY_FAMILY' as const,
|
||||
typeConfigured: false,
|
||||
configurationReason: 'El elemento todavía no tiene una clasificación técnica activa.',
|
||||
configurationReason: 'El elemento todavía no tiene tipo de instalación/subinstalación activo.',
|
||||
categories: [],
|
||||
items: [],
|
||||
other,
|
||||
@@ -120,7 +120,7 @@ export class F3FindingCatalogResolverService {
|
||||
},
|
||||
catalogSource: 'INVENTORY_FAMILY' as const,
|
||||
typeConfigured: true,
|
||||
configurationReason: `Hallazgos asociados a la clasificación técnica ${asset.familyName ?? asset.familyCode ?? ''}`.trim(),
|
||||
configurationReason: `Hallazgos asociados al tipo ${asset.familyName ?? asset.familyCode ?? ''}`.trim(),
|
||||
categories,
|
||||
items,
|
||||
other,
|
||||
|
||||
@@ -153,6 +153,22 @@ export class InspectionEvidenceService {
|
||||
assertMobileInspector(principal);
|
||||
}
|
||||
this.validateCoordinates(dto.latitude, dto.longitude, dto.accuracyM);
|
||||
if (dto.operationId) {
|
||||
const [existing] = await this.dataSource.query(
|
||||
`${this.evidenceSelect()} WHERE evidence.client_operation_id = $1`,
|
||||
[dto.operationId],
|
||||
) as StoredInspectionEvidence[];
|
||||
if (existing) {
|
||||
if (existing.findingId !== findingId) {
|
||||
throw new ConflictException({
|
||||
code: 'INSPECTION_EVIDENCE_OPERATION_REUSED',
|
||||
message: 'La operación offline ya fue utilizada para otra evidencia',
|
||||
});
|
||||
}
|
||||
const { storedName: _storedName, ...view } = existing;
|
||||
return view;
|
||||
}
|
||||
}
|
||||
const inspected = inspectInspectionEvidenceFile(file, dto.kind);
|
||||
this.validatePurpose(dto, inspected.mimeType);
|
||||
|
||||
@@ -210,12 +226,12 @@ export class InspectionEvidenceService {
|
||||
id, finding_id, communication_id, verification_visit_id, kind, purpose,
|
||||
original_name, stored_name, mime_type, size_bytes, sha256,
|
||||
title, description, captured_at, latitude, longitude, accuracy_m,
|
||||
device_label, source, uploaded_by
|
||||
device_label, source, uploaded_by, client_operation_id
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6,
|
||||
$7, $8, $9, $10, $11,
|
||||
$12, $13, $14, $15, $16, $17,
|
||||
$18, $19, $20
|
||||
$18, $19, $20, $21
|
||||
)
|
||||
`, [
|
||||
id,
|
||||
@@ -238,6 +254,7 @@ export class InspectionEvidenceService {
|
||||
dto.deviceLabel?.trim() || null,
|
||||
source,
|
||||
principal.userId,
|
||||
dto.operationId ?? null,
|
||||
]);
|
||||
const created = await this.loadEvidence(manager, id);
|
||||
await this.audit.record({
|
||||
|
||||
@@ -398,6 +398,22 @@ export class InspectionFindingsService {
|
||||
const visit = await this.lockVisit(manager, act.visitId);
|
||||
this.assertActEditable(act, visit);
|
||||
await this.assertActorAssigned(manager, visit.id, principal);
|
||||
if (dto.clientGeneratedId) {
|
||||
const [existing] = await manager.query(`
|
||||
SELECT id, act_id AS "actId", asset_id AS "assetId"
|
||||
FROM inspection_findings
|
||||
WHERE id=$1
|
||||
`, [dto.clientGeneratedId]) as Array<{ id: string; actId: string; assetId: string }>;
|
||||
if (existing) {
|
||||
if (existing.actId !== actId || existing.assetId !== dto.assetId) {
|
||||
throw new ConflictException({
|
||||
code: 'INSPECTION_FINDING_CLIENT_ID_REUSED',
|
||||
message: 'El identificador offline del Hallazgo ya fue utilizado en otro contexto',
|
||||
});
|
||||
}
|
||||
return this.loadView(manager, existing.id);
|
||||
}
|
||||
}
|
||||
await this.assertActAsset(manager, actId, dto.assetId);
|
||||
|
||||
const catalog = dto.catalogItemId
|
||||
@@ -418,6 +434,7 @@ export class InspectionFindingsService {
|
||||
});
|
||||
}
|
||||
const finding = manager.getRepository(InspectionFinding).create({
|
||||
id: dto.clientGeneratedId,
|
||||
actId,
|
||||
assetId: dto.assetId,
|
||||
catalogItemId: catalog?.id ?? null,
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from './inspection-report-workflow.service';
|
||||
import { InspectionReportsService } from './inspection-reports.service';
|
||||
import { InspectionReportWordService } from './inspection-report-word.service';
|
||||
import { InspectionActPdfService } from './inspection-act-pdf.service';
|
||||
|
||||
@Controller('inspection-reports')
|
||||
export class InspectionReportsController {
|
||||
@@ -148,3 +149,24 @@ export class InspectionActReportController {
|
||||
return this.reports.generate(actId, principal, request);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('inspection-acts/:actId/pdf')
|
||||
export class InspectionActPdfController {
|
||||
constructor(private readonly pdf: InspectionActPdfService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('inspection_acts.read')
|
||||
async content(
|
||||
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
|
||||
@Res() response: Response,
|
||||
) {
|
||||
await this.pdf.ensure(actId);
|
||||
const content = await this.pdf.content(actId);
|
||||
response.setHeader('Content-Type', content.mimeType);
|
||||
response.setHeader('Content-Length', String(content.buffer.length));
|
||||
response.setHeader('Content-Disposition', `inline; filename="${content.originalName.replaceAll('"', '')}"`);
|
||||
response.setHeader('Cache-Control', 'private, no-store');
|
||||
response.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
return response.send(content.buffer);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { InspectionDeadlineAdminService } from './inspection-deadline-admin.serv
|
||||
import { InspectionDocumentDeliveryService } from './inspection-document-delivery.service';
|
||||
import { InspectionReportWorkflowService } from './inspection-report-workflow.service';
|
||||
import { InspectionReportWordService } from './inspection-report-word.service';
|
||||
import { InspectionActReportController, InspectionReportsController } from './inspection-reports.controller';
|
||||
import { InspectionActPdfController, InspectionActReportController, InspectionReportsController } from './inspection-reports.controller';
|
||||
import { InspectionReportsService } from './inspection-reports.service';
|
||||
import { SmtpDeliveryService } from './smtp-delivery.service';
|
||||
|
||||
@@ -16,6 +16,7 @@ import { SmtpDeliveryService } from './smtp-delivery.service';
|
||||
controllers: [
|
||||
InspectionReportsController,
|
||||
InspectionActReportController,
|
||||
InspectionActPdfController,
|
||||
InspectionDeadlineAdminController,
|
||||
DocumentDeliveryController,
|
||||
],
|
||||
|
||||
@@ -23,6 +23,10 @@ export class CreateFieldFindingDto {
|
||||
@IsUUID('4')
|
||||
actId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
clientGeneratedId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(optionalText)
|
||||
@IsUUID('4')
|
||||
|
||||
@@ -14,6 +14,10 @@ import {
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateFieldInventoryDto {
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
clientGeneratedId?: string;
|
||||
|
||||
@IsUUID('4')
|
||||
typeId!: string;
|
||||
|
||||
|
||||
@@ -4,12 +4,17 @@ import {
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UploadFieldInventoryPhotoDto {
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
operationId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
|
||||
@IsString()
|
||||
|
||||
@@ -101,13 +101,13 @@ export class F3FieldInventoryStructureService {
|
||||
if (familyRequired && !dto.familyId) {
|
||||
throw new BadRequestException({
|
||||
code: 'FIELD_INVENTORY_FAMILY_REQUIRED',
|
||||
message: 'Elegí la familia técnica o la opción Otro / no catalogado',
|
||||
message: 'Elegí el Tipo de instalación/subinstalación o la opción Otro / no catalogado',
|
||||
});
|
||||
}
|
||||
if (!familyRequired && dto.familyId) {
|
||||
throw new BadRequestException({
|
||||
code: 'FIELD_INVENTORY_FAMILY_NOT_ALLOWED',
|
||||
message: 'Este nivel estructural no utiliza familia técnica',
|
||||
message: 'Este nivel estructural no utiliza Tipo de instalación/subinstalación',
|
||||
});
|
||||
}
|
||||
const family = dto.familyId
|
||||
@@ -116,7 +116,7 @@ export class F3FieldInventoryStructureService {
|
||||
if (dto.familyId && !family) {
|
||||
throw new BadRequestException({
|
||||
code: 'FIELD_INVENTORY_FAMILY_INVALID',
|
||||
message: 'La familia técnica no es válida para el padre seleccionado',
|
||||
message: 'El Tipo de instalación/subinstalación no es válido para el padre seleccionado',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -214,7 +214,7 @@ export class F3FieldInventoryStructureService {
|
||||
if (!parent.inventoryFamilyId) {
|
||||
throw new ConflictException({
|
||||
code: 'FIELD_INVENTORY_PARENT_FAMILY_REQUIRED',
|
||||
message: 'La Instalación debe tener una familia técnica antes de agregar Subinstalaciones',
|
||||
message: 'La Instalación debe tener un Tipo de instalación antes de agregar Subinstalaciones',
|
||||
});
|
||||
}
|
||||
return this.dataSource.query(`
|
||||
|
||||
@@ -99,6 +99,7 @@ export class FieldFindingsService {
|
||||
});
|
||||
}
|
||||
const payload: CreateInspectionFindingDto = {
|
||||
clientGeneratedId: dto.clientGeneratedId,
|
||||
assetId,
|
||||
catalogItemId: dto.catalogItemId ?? null,
|
||||
customTitle: dto.customTitle ?? null,
|
||||
@@ -278,7 +279,7 @@ export class FieldFindingsService {
|
||||
if (assetTypeCode !== 'yacimiento' && !row.inventoryFamilyId) {
|
||||
throw new ConflictException({
|
||||
code: 'FIELD_FINDING_FAMILY_REQUIRED',
|
||||
message: 'La Instalación/Subinstalación debe tener una clasificación técnica antes de registrar Hallazgos',
|
||||
message: 'La Instalación/Subinstalación debe tener su tipo definido antes de registrar Hallazgos',
|
||||
});
|
||||
}
|
||||
if (!row.insideScope) {
|
||||
|
||||
@@ -273,6 +273,23 @@ export class FieldInventoryService {
|
||||
request: RequestWithContext,
|
||||
) {
|
||||
const context = await this.requireVisitContext(visitId, principal, true);
|
||||
if (dto.clientGeneratedId) {
|
||||
const [existing] = await this.dataSource.query(`
|
||||
SELECT discovery.asset_id AS "assetId", discovery.visit_id AS "visitId"
|
||||
FROM asset_field_discoveries discovery
|
||||
WHERE discovery.asset_id=$1::uuid
|
||||
LIMIT 1
|
||||
`, [dto.clientGeneratedId]) as Array<{ assetId: string; visitId: string }>;
|
||||
if (existing) {
|
||||
if (existing.visitId !== visitId) {
|
||||
throw new ConflictException({
|
||||
code: 'FIELD_INVENTORY_CLIENT_ID_REUSED',
|
||||
message: 'El identificador offline del Inventario ya fue utilizado en otra inspección',
|
||||
});
|
||||
}
|
||||
return this.detail(visitId, existing.assetId, principal);
|
||||
}
|
||||
}
|
||||
const parentId = dto.parentId ?? context.scopeAssetId;
|
||||
await this.requireParentInContext(parentId, context);
|
||||
|
||||
@@ -286,6 +303,7 @@ export class FieldInventoryService {
|
||||
|
||||
const code = dto.code?.trim().toUpperCase() || await this.nextFieldCode(capturedAt);
|
||||
const discoveryDto: CreateFieldDiscoveryDto = {
|
||||
clientGeneratedId: dto.clientGeneratedId,
|
||||
visitId,
|
||||
code,
|
||||
name: dto.name,
|
||||
@@ -351,6 +369,28 @@ export class FieldInventoryService {
|
||||
): Promise<{ media: AssetMediaView; capture: FieldInventoryCaptureStatus }> {
|
||||
const context = await this.requireVisitContext(visitId, principal, true);
|
||||
await this.requireAssetInContext(assetId, context);
|
||||
if (dto.operationId) {
|
||||
const [existing] = await this.dataSource.query(`
|
||||
SELECT media_id AS "mediaId", asset_id AS "assetId"
|
||||
FROM asset_field_capture_events
|
||||
WHERE client_operation_id=$1::uuid
|
||||
LIMIT 1
|
||||
`, [dto.operationId]) as Array<{ mediaId: string | null; assetId: string }>;
|
||||
if (existing) {
|
||||
if (existing.assetId !== assetId || !existing.mediaId) {
|
||||
throw new ConflictException({
|
||||
code: 'FIELD_PHOTO_OPERATION_REUSED',
|
||||
message: 'La operación offline de fotografía ya fue utilizada en otro Inventario',
|
||||
});
|
||||
}
|
||||
const media = (await this.media.list(assetId)).data.find((item) => item.id === existing.mediaId);
|
||||
if (!media) throw new ConflictException({
|
||||
code: 'FIELD_PHOTO_OPERATION_MEDIA_MISSING',
|
||||
message: 'La fotografía sincronizada no está disponible',
|
||||
});
|
||||
return { media, capture: await this.captureStatus(visitId, assetId) };
|
||||
}
|
||||
}
|
||||
if (!await this.isSelected(visitId, assetId)) {
|
||||
throw new ConflictException({
|
||||
code: 'FIELD_INVENTORY_NOT_SELECTED',
|
||||
@@ -383,8 +423,8 @@ export class FieldInventoryService {
|
||||
device_latitude, device_longitude, device_accuracy_m,
|
||||
device_captured_at, device_label,
|
||||
exif_latitude, exif_longitude, exif_captured_at,
|
||||
created_by
|
||||
) VALUES ($1,$2,$3,'PHOTO',$4,$5,$6,$7,$8,$9,$10,$11,$12)
|
||||
created_by, client_operation_id
|
||||
) VALUES ($1,$2,$3,'PHOTO',$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
||||
`, [
|
||||
visitId,
|
||||
assetId,
|
||||
@@ -398,6 +438,7 @@ export class FieldInventoryService {
|
||||
dto.exifLongitude ?? null,
|
||||
dto.exifCapturedAt ? new Date(dto.exifCapturedAt) : null,
|
||||
principal.userId,
|
||||
dto.operationId ?? null,
|
||||
]);
|
||||
|
||||
return { media: uploaded, capture: await this.captureStatus(visitId, assetId) };
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export const API_VERSION = '0.29.0-7';
|
||||
export const API_PHASE = 'F6.7';
|
||||
export const API_VERSION = '0.29.0-8';
|
||||
export const API_PHASE = 'F6.8';
|
||||
|
||||
@@ -34,9 +34,9 @@ test('F4 Android uses only lock and seal endpoints for the active Act lifecycle'
|
||||
assert.doesNotMatch(mobileActs, /suspend fun closeAct\(/);
|
||||
});
|
||||
|
||||
test('F4 Android ViewModel calls lock and seal directly', () => {
|
||||
assert.match(viewModel, /actsRepository\.lock\(actId, urgency\)/);
|
||||
assert.match(viewModel, /actsRepository\.seal\(actId\)/);
|
||||
test('F6.8 Android ViewModel persists lock and seal through the offline queue', () => {
|
||||
assert.match(viewModel, /offlineQueue\.enqueueActLock\(actId, urgency\)/);
|
||||
assert.match(viewModel, /offlineQueue\.enqueueSeal\(actId\)/);
|
||||
assert.doesNotMatch(viewModel, /actsRepository\.prepare\(/);
|
||||
assert.doesNotMatch(viewModel, /actsRepository\.reopen\(/);
|
||||
assert.doesNotMatch(viewModel, /actsRepository\.closeAct\(/);
|
||||
|
||||
@@ -4,9 +4,9 @@ import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { API_PHASE, API_VERSION } from '../../src/version';
|
||||
|
||||
test('health metadata reports the current F6.7 release', () => {
|
||||
assert.equal(API_PHASE, 'F6.7');
|
||||
test('health metadata reports the current F6.8 release', () => {
|
||||
assert.equal(API_PHASE, 'F6.8');
|
||||
const pkg = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8')) as { version: string };
|
||||
assert.equal(API_VERSION, pkg.version);
|
||||
assert.equal(API_VERSION, '0.29.0-7');
|
||||
assert.equal(API_VERSION, '0.29.0-8');
|
||||
});
|
||||
@@ -63,7 +63,7 @@ test('F5.1 Finding Catalog defaults to associated findings and exposes all items
|
||||
assert.match(panel, /available\.filter\(\(item\) => savedIds\.has\(item\.id\)\)/);
|
||||
assert.match(panel, /Buscar dentro de \{viewMode === 'ASSOCIATED' \? 'los asociados' : 'todo el Catálogo'\}/);
|
||||
assert.match(panel, /Todos para vincular/);
|
||||
assert.match(panel, /Esta clasificación todavía no tiene Hallazgos asociados/);
|
||||
assert.match(panel, /Este tipo todavía no tiene Hallazgos asociados/);
|
||||
});
|
||||
|
||||
test('F7 Inventory Configuration exposes only simple types, containment and fields', () => {
|
||||
@@ -116,7 +116,7 @@ test('F7 canonical detail uses one short everyday profile and keeps advanced adm
|
||||
assert.match(detail, />Fotos</);
|
||||
assert.match(detail, />Cambios</);
|
||||
assert.match(detail, /Empresa operadora/);
|
||||
assert.match(detail, /Tipo técnico/);
|
||||
assert.match(detail, /Tipo de instalación \/ subinstalación/);
|
||||
assert.match(detail, /AssetTechnicalDataPanel/);
|
||||
assert.match(detail, /AssetDossierPanel/);
|
||||
assert.match(detail, /Administración avanzada/);
|
||||
|
||||
@@ -10,8 +10,8 @@ function mountedRepoFile(path: string): string {
|
||||
test('F6.3 Android test cut targets production API and has a distinct installable debug version', () => {
|
||||
const gradle = mountedRepoFile('android-app/app/build.gradle.kts');
|
||||
|
||||
assert.match(gradle, /versionCode = 37/);
|
||||
assert.match(gradle, /versionName = "0\.19\.9"/);
|
||||
assert.match(gradle, /versionCode = 38/);
|
||||
assert.match(gradle, /versionName = "0\.19\.10"/);
|
||||
assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//);
|
||||
assert.match(gradle, /applicationIdSuffix = "\.debug"/);
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ test('F6.1 presentation metadata keeps the visible WEB version aligned with pack
|
||||
const visibleVersion = version.match(/APP_VERSION\s*=\s*'([^']+)'/)?.[1];
|
||||
|
||||
assert.equal(visibleVersion, pkg.version);
|
||||
assert.match(version, /APP_PHASE\s*=\s*'F6\.7 · Cierre y firma por Acta'/);
|
||||
assert.match(version, /APP_PHASE\s*=\s*'F6\.8/);
|
||||
});
|
||||
|
||||
test('F6.1 presentation keeps Relevamientos retired from WEB navigation and routes', () => {
|
||||
@@ -49,7 +49,7 @@ test('F6.1 presentation keeps Android start, Otro and chronological merge flows
|
||||
assert.match(mobileApi, /field-inventory\/\{assetId\}\/merge/);
|
||||
assert.match(overview, /"Iniciar inspección"/);
|
||||
assert.match(overview, /Otro \/ no catalogado/);
|
||||
assert.match(viewModel, /repository\.startVisit\(id\)/);
|
||||
assert.match(viewModel, /enqueueVisitStart\(id\)|repository\.startVisit\(id\)/);
|
||||
assert.match(viewModel, /selectedFieldAsset = repository\.selectFieldAsset\(visitId, result\.canonical\.id\)/);
|
||||
assert.match(viewModel, /la historia de \$\{result\.source\.code\} permanece trazable/);
|
||||
});
|
||||
|
||||
@@ -36,11 +36,11 @@ test('F6.7 Android goes Draft to Para firmar to company manifestation without in
|
||||
assert.match(labels, /"LOCKED" -> "Para firmar"/);
|
||||
assert.match(android, /Cerrar contenido y pasar a firma/);
|
||||
assert.match(android, /Firma del representante de la empresa \(acompañante\)/);
|
||||
assert.match(android, /Aplicar mi firma y cerrar Acta/);
|
||||
assert.match(android, /cerrar Acta/);
|
||||
assert.doesNotMatch(android, /Firmá como inspector\/a/);
|
||||
assert.doesNotMatch(android, /inspectorSigned/);
|
||||
assert.doesNotMatch(android, /Text\(selected\.code/);
|
||||
assert.match(android, /companyOutcome\?\.status == "ABSENT"/);
|
||||
assert.match(android, /attendanceStatus == "ABSENT"/);
|
||||
});
|
||||
test('F6.7 Dashboard exposes the reusable inspector signature clearly', () => {
|
||||
assert.match(profile, /FIRMA DEL INSPECTOR/);
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
const api = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8');
|
||||
const android = (path: string) => readFileSync(resolve(process.cwd(), '..', 'android-app', path), 'utf8');
|
||||
const web = (path: string) => readFileSync(resolve(process.cwd(), '..', 'web-v2', path), 'utf8');
|
||||
|
||||
test('F6.8 persists field mutations locally and retries only when network is available', () => {
|
||||
const queue = android('app/src/main/java/com/korexlabs/dhinspeccion/data/offline/OfflineQueue.kt');
|
||||
assert.match(queue, /@Database\(entities = \[PendingMobileOperation::class, OfflineCacheEntry::class\]/);
|
||||
assert.match(queue, /NetworkType\.CONNECTED/);
|
||||
assert.match(queue, /OneTimeWorkRequestBuilder<OfflineSyncWorker>/);
|
||||
for (const operation of [
|
||||
'VISIT_START', 'VISIT_CLOSE', 'FIELD_ASSET_CREATE', 'FIELD_ASSET_PHOTO',
|
||||
'ACT_CREATE', 'ACT_ASSET_ENSURE', 'FINDING_CREATE', 'FINDING_PHOTO',
|
||||
'ACT_RESPONSIBLE', 'ACT_LOCK', 'COMPANY_OUTCOME', 'COMPANY_SIGNATURE', 'ACT_SEAL',
|
||||
]) assert.match(queue, new RegExp(`"${operation}"`));
|
||||
});
|
||||
|
||||
test('F6.8 isolates offline work and cached inspection data by authenticated user', () => {
|
||||
const queue = android('app/src/main/java/com/korexlabs/dhinspeccion/data/offline/OfflineQueue.kt');
|
||||
const mobile = android('app/src/main/java/com/korexlabs/dhinspeccion/data/DhMobile.kt');
|
||||
const acts = android('app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt');
|
||||
const findings = android('app/src/main/java/com/korexlabs/dhinspeccion/data/FieldFindingsMobile.kt');
|
||||
assert.match(queue, /val ownerUserId: String/);
|
||||
assert.match(queue, /ownerUserId=:ownerUserId/);
|
||||
assert.match(queue, /SecureSessionStore/);
|
||||
assert.match(mobile, /userCacheKey/);
|
||||
assert.match(acts, /userCacheKey/);
|
||||
assert.match(findings, /userCacheKey/);
|
||||
});
|
||||
|
||||
test('F6.8 gives offline-created objects idempotent client identities', () => {
|
||||
const actDto = api('src/inspection-acts/dto/create-inspection-act.dto.ts');
|
||||
const findingDto = api('src/inspection-findings/dto/create-inspection-finding.dto.ts');
|
||||
const inventoryDto = api('src/inspection-visits/dto/create-field-inventory.dto.ts');
|
||||
const migration = api('src/database/migrations/1790128200000-f6-8-offline-evidence-idempotency.ts');
|
||||
assert.match(actDto, /clientGeneratedId/);
|
||||
assert.match(findingDto, /clientGeneratedId/);
|
||||
assert.match(inventoryDto, /clientGeneratedId/);
|
||||
assert.match(migration, /inspection_finding_evidence/);
|
||||
assert.match(migration, /asset_field_capture_events/);
|
||||
assert.equal((migration.match(/client_operation_id/g) ?? []).length >= 6, true);
|
||||
});
|
||||
|
||||
test('F6.8 reviews photos before queueing and never sends the retired ONLINE upload mode', () => {
|
||||
const photo = android('app/src/main/java/com/korexlabs/dhinspeccion/ui/FieldFindingScreen.kt');
|
||||
const acts = android('app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt');
|
||||
assert.match(photo, /Revisar fotografía/);
|
||||
assert.match(photo, /Usar esta foto/);
|
||||
assert.match(photo, /Volver a tomar/);
|
||||
assert.match(photo, /Eliminar foto/);
|
||||
assert.doesNotMatch(acts, /uploadMode: String = "ONLINE"/);
|
||||
assert.match(acts, /uploadMode: String = "IMMEDIATE"/);
|
||||
});
|
||||
|
||||
test('F6.8 makes the real Acta PDF and photographic record first-class in Dashboard', () => {
|
||||
const controller = api('src/inspection-reports/inspection-reports.controller.ts');
|
||||
const page = web('src/pages/InspectionActEditorPage.tsx');
|
||||
const media = web('src/features/inspections/InspectionActMediaPanel.tsx');
|
||||
assert.match(controller, /inspection-acts\/:actId\/pdf/);
|
||||
assert.match(api('src/inspection-acts/inspection-acts.controller.ts'), /:id\/field-media/);
|
||||
assert.match(api('src/inspection-acts/inspection-acts.service.ts'), /capture\.visit_id=act\.visit_id/);
|
||||
assert.match(page, /Abrir PDF del Acta/);
|
||||
assert.match(page, /Descargar PDF/);
|
||||
assert.match(media, /Fotos y Hallazgos/);
|
||||
assert.match(media, /Evidencia fotográfica/);
|
||||
assert.match(media, /Fotos tomadas durante esta inspección/);
|
||||
});
|
||||
|
||||
test('F6.8 presents technical families to users as installation and subinstallation types', () => {
|
||||
const config = web('src/pages/AssetTypesPage.tsx');
|
||||
const detail = web('src/pages/SimpleInventoryDetailPage.tsx');
|
||||
const field = api('src/inspection-visits/f3-field-inventory-structure.service.ts');
|
||||
assert.match(config, /Tipos de Instalación/);
|
||||
assert.match(config, /Tipos de Subinstalación/);
|
||||
assert.match(detail, /Tipo de instalación \/ subinstalación/);
|
||||
assert.match(field, /Tipo de instalación\/subinstalación/);
|
||||
assert.doesNotMatch(config, /clasificación técnica/i);
|
||||
assert.doesNotMatch(detail, /clasificación técnica/i);
|
||||
});
|
||||
Reference in New Issue
Block a user