From 7597f7d2faecfd58d150c3b3a7cb8d19a0868670 Mon Sep 17 00:00:00 2001 From: daniel Date: Tue, 30 Jun 2026 12:17:04 +0200 Subject: [PATCH] feat(emergencies): umbral de disputas configurable por emergencia (#169) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Permite que coordinadores y operadores fijen un umbral de reportes de validez por emergencia (PUT /emergencies/:id/resource-dispute-threshold), sobreescribiendo el umbral global RESOURCE_DISPUTE_THRESHOLD cuando está definido. Si el campo es null, se cae de nuevo al global. - Migración 0042: columna resource_dispute_threshold (nullable integer) - Emergency.setResourceDisputeThreshold() + getter en el dominio - Nuevo puerto EmergencyDisputeThresholdReader + adaptador Drizzle (cross-context, patrón aceptado) - Caso de uso SetEmergencyDisputeThreshold con tests - ReportResourceValidity lee el umbral por emergencia antes de comparar - Permiso emergency:configure añadido al catálogo; concedido a platform_operator y emergency_coordinator - Endpoint PUT :emergencyId/resource-dispute-threshold con guard PermissionGuard - schema.ts regenerado con el nuevo endpoint --- .../0042_emergency_dispute_threshold.sql | 5 ++ apps/api/openapi.json | 67 ++++++++++++++++ .../set-emergency-dispute-threshold.spec.ts | 80 +++++++++++++++++++ .../set-emergency-dispute-threshold.ts | 20 +++++ .../contexts/emergencies/domain/emergency.ts | 14 ++++ .../drizzle/drizzle-emergency.repository.ts | 2 + .../infrastructure/drizzle/schema.ts | 3 +- .../infrastructure/emergencies.module.ts | 9 +++ .../emergencies/infrastructure/http/dto.ts | 17 ++++ .../http/emergencies.controller.ts | 34 ++++++++ .../domain/authorization/permission.ts | 1 + .../domain/authorization/role-catalog.ts | 2 + .../report-resource-validity.spec.ts | 39 ++++++++- .../application/report-resource-validity.ts | 8 +- .../emergency-dispute-threshold-reader.ts | 8 ++ .../infrastructure/resources.module.ts | 29 ++++++- ...zzle-emergency-dispute-threshold-reader.ts | 24 ++++++ packages/api-client/src/schema.ts | 77 ++++++++++++++++++ 18 files changed, 433 insertions(+), 6 deletions(-) create mode 100644 apps/api/drizzle/0042_emergency_dispute_threshold.sql create mode 100644 apps/api/src/contexts/emergencies/application/set-emergency-dispute-threshold.spec.ts create mode 100644 apps/api/src/contexts/emergencies/application/set-emergency-dispute-threshold.ts create mode 100644 apps/api/src/contexts/resources/domain/ports/emergency-dispute-threshold-reader.ts create mode 100644 apps/api/src/shared/drizzle-emergency-dispute-threshold-reader.ts diff --git a/apps/api/drizzle/0042_emergency_dispute_threshold.sql b/apps/api/drizzle/0042_emergency_dispute_threshold.sql new file mode 100644 index 00000000..114752a3 --- /dev/null +++ b/apps/api/drizzle/0042_emergency_dispute_threshold.sql @@ -0,0 +1,5 @@ +-- Umbral configurable de disputa por emergencia (#169). +-- Si es NULL, se usa el valor global (RESOURCE_DISPUTE_THRESHOLD env / 3). +ALTER TABLE emergencies + ADD COLUMN IF NOT EXISTS resource_dispute_threshold integer; +--> statement-breakpoint diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 7ce44d75..05c256d4 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -2555,6 +2555,59 @@ ] } }, + "/emergencies/{emergencyId}/resource-dispute-threshold": { + "put": { + "operationId": "EmergenciesController_setResourceDisputeThreshold", + "parameters": [ + { + "name": "emergencyId", + "required": true, + "in": "path", + "description": "Emergency UUID", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetDisputeThresholdDto" + } + } + } + }, + "responses": { + "204": { + "description": "Umbral actualizado" + }, + "400": { + "description": "Invalid body" + }, + "401": { + "description": "Missing or invalid token" + }, + "403": { + "description": "emergency:configure permission required" + }, + "404": { + "description": "Emergency not found" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Fijar (o limpiar) el umbral de disputa por reportes para una emergencia", + "tags": [ + "emergencies" + ] + } + }, "/emergencies/{emergencyId}/needs": { "post": { "description": "Open to any authenticated user (a citizen submits; a coordinator validates later). A trusted integration may also create on behalf of a third party with its service-account API key when it holds `need:create` and includes the `author` block (#235).", @@ -10921,6 +10974,20 @@ "message" ] }, + "SetDisputeThresholdDto": { + "type": "object", + "properties": { + "threshold": { + "type": "number", + "example": 5, + "nullable": true, + "description": "Número mínimo de reportantes distintos para marcar el punto como disputado. Null elimina el umbral específico y vuelve al global (RESOURCE_DISPUTE_THRESHOLD / 3)." + } + }, + "required": [ + "threshold" + ] + }, "NeedLocationDto": { "type": "object", "properties": { diff --git a/apps/api/src/contexts/emergencies/application/set-emergency-dispute-threshold.spec.ts b/apps/api/src/contexts/emergencies/application/set-emergency-dispute-threshold.spec.ts new file mode 100644 index 00000000..09fb3b3d --- /dev/null +++ b/apps/api/src/contexts/emergencies/application/set-emergency-dispute-threshold.spec.ts @@ -0,0 +1,80 @@ +import { SetEmergencyDisputeThreshold } from './set-emergency-dispute-threshold'; +import { EmergencyNotFoundError } from './emergency-not-found.error'; +import { Emergency } from '../domain/emergency'; +import { EmergencyStatus } from '../domain/emergency-status'; +import { EmergencyRepository } from '../domain/ports/emergency.repository'; + +const SNAP = { + id: '11111111-1111-4111-8111-111111111111', + name: 'Terremoto Venezuela 2026', + slug: 'terremoto-venezuela-2026', + country: 'VE', + status: EmergencyStatus.Active, + announcement: null, + dontBringList: [], + resourceDisputeThreshold: null, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), +}; + +function makeRepo(emergency: Emergency | null): { + repo: EmergencyRepository; + saveMock: jest.Mock; + saved: () => Emergency | null; +} { + let saved: Emergency | null = null; + const saveMock = jest.fn((e: Emergency) => { + saved = e; + return Promise.resolve(); + }); + const repo: EmergencyRepository = { + save: saveMock, + findById: jest.fn().mockResolvedValue(emergency), + findBySlug: jest.fn().mockResolvedValue(null), + findByIds: jest.fn().mockResolvedValue([]), + listActive: jest.fn().mockResolvedValue([]), + }; + return { repo, saveMock, saved: () => saved }; +} + +describe('SetEmergencyDisputeThreshold', () => { + it('persiste el umbral cuando se pasa un valor positivo', async () => { + const emergency = Emergency.fromSnapshot(SNAP); + const { repo, saveMock, saved } = makeRepo(emergency); + + await new SetEmergencyDisputeThreshold(repo).execute({ + emergencyId: SNAP.id, + threshold: 5, + }); + + expect(saveMock).toHaveBeenCalledTimes(1); + expect(saved()!.resourceDisputeThreshold).toBe(5); + }); + + it('limpia el umbral cuando se pasa null (vuelve al global)', async () => { + const emergency = Emergency.fromSnapshot({ + ...SNAP, + resourceDisputeThreshold: 5, + }); + const { repo, saved } = makeRepo(emergency); + + await new SetEmergencyDisputeThreshold(repo).execute({ + emergencyId: SNAP.id, + threshold: null, + }); + + expect(saved()!.resourceDisputeThreshold).toBeNull(); + }); + + it('lanza EmergencyNotFoundError si la emergencia no existe', async () => { + const { repo, saveMock } = makeRepo(null); + + await expect( + new SetEmergencyDisputeThreshold(repo).execute({ + emergencyId: SNAP.id, + threshold: 5, + }), + ).rejects.toBeInstanceOf(EmergencyNotFoundError); + expect(saveMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/contexts/emergencies/application/set-emergency-dispute-threshold.ts b/apps/api/src/contexts/emergencies/application/set-emergency-dispute-threshold.ts new file mode 100644 index 00000000..25e9984d --- /dev/null +++ b/apps/api/src/contexts/emergencies/application/set-emergency-dispute-threshold.ts @@ -0,0 +1,20 @@ +import { EmergencyRepository } from '../domain/ports/emergency.repository'; +import { EmergencyId } from '../../../shared/domain/emergency-id'; +import { EmergencyNotFoundError } from './emergency-not-found.error'; + +export interface SetEmergencyDisputeThresholdCommand { + emergencyId: string; + threshold: number | null; +} + +export class SetEmergencyDisputeThreshold { + constructor(private readonly repo: EmergencyRepository) {} + + async execute(cmd: SetEmergencyDisputeThresholdCommand): Promise { + const id = EmergencyId.fromString(cmd.emergencyId); + const emergency = await this.repo.findById(id); + if (!emergency) throw new EmergencyNotFoundError(cmd.emergencyId); + emergency.setResourceDisputeThreshold(cmd.threshold); + await this.repo.save(emergency); + } +} diff --git a/apps/api/src/contexts/emergencies/domain/emergency.ts b/apps/api/src/contexts/emergencies/domain/emergency.ts index 2b73cafd..febddc0a 100644 --- a/apps/api/src/contexts/emergencies/domain/emergency.ts +++ b/apps/api/src/contexts/emergencies/domain/emergency.ts @@ -20,6 +20,7 @@ export interface EmergencySnapshot { status: EmergencyStatus; announcement: string | null; dontBringList: string[]; + resourceDisputeThreshold: number | null; createdAt: Date; updatedAt: Date; } @@ -33,6 +34,7 @@ export class Emergency { private _status: EmergencyStatus, private _announcement: string | null, private _dontBringList: string[], + private _resourceDisputeThreshold: number | null, public readonly createdAt: Date, private _updatedAt: Date, ) {} @@ -47,6 +49,7 @@ export class Emergency { EmergencyStatus.Active, props.announcement ?? null, props.dontBringList ?? [], + null, now, now, ); @@ -61,6 +64,7 @@ export class Emergency { snap.status, snap.announcement, snap.dontBringList, + snap.resourceDisputeThreshold, snap.createdAt, snap.updatedAt, ); @@ -78,6 +82,10 @@ export class Emergency { return this._dontBringList; } + get resourceDisputeThreshold(): number | null { + return this._resourceDisputeThreshold; + } + get updatedAt(): Date { return this._updatedAt; } @@ -108,6 +116,11 @@ export class Emergency { this._updatedAt = new Date(); } + setResourceDisputeThreshold(threshold: number | null): void { + this._resourceDisputeThreshold = threshold; + this._updatedAt = new Date(); + } + toSnapshot(): EmergencySnapshot { return { id: this.id.value, @@ -117,6 +130,7 @@ export class Emergency { status: this._status, announcement: this._announcement, dontBringList: this._dontBringList, + resourceDisputeThreshold: this._resourceDisputeThreshold, createdAt: this.createdAt, updatedAt: this._updatedAt, }; diff --git a/apps/api/src/contexts/emergencies/infrastructure/drizzle/drizzle-emergency.repository.ts b/apps/api/src/contexts/emergencies/infrastructure/drizzle/drizzle-emergency.repository.ts index f2478e6b..e13aea85 100644 --- a/apps/api/src/contexts/emergencies/infrastructure/drizzle/drizzle-emergency.repository.ts +++ b/apps/api/src/contexts/emergencies/infrastructure/drizzle/drizzle-emergency.repository.ts @@ -18,6 +18,7 @@ function rowToSnapshot(row: Row): EmergencySnapshot { status: row.status as EmergencyStatus, announcement: row.announcement ?? null, dontBringList: row.dontBringList, + resourceDisputeThreshold: row.resourceDisputeThreshold ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, }; @@ -49,6 +50,7 @@ export class DrizzleEmergencyRepository implements EmergencyRepository { country: s.country, announcement: s.announcement, dontBringList: s.dontBringList, + resourceDisputeThreshold: s.resourceDisputeThreshold, updatedAt: s.updatedAt, }, }); diff --git a/apps/api/src/contexts/emergencies/infrastructure/drizzle/schema.ts b/apps/api/src/contexts/emergencies/infrastructure/drizzle/schema.ts index c39acb4c..2fa3e90b 100644 --- a/apps/api/src/contexts/emergencies/infrastructure/drizzle/schema.ts +++ b/apps/api/src/contexts/emergencies/infrastructure/drizzle/schema.ts @@ -1,4 +1,4 @@ -import { pgTable, uuid, text, timestamp } from 'drizzle-orm/pg-core'; +import { pgTable, uuid, text, timestamp, integer } from 'drizzle-orm/pg-core'; export const emergenciesTable = pgTable('emergencies', { id: uuid('id').primaryKey(), @@ -8,6 +8,7 @@ export const emergenciesTable = pgTable('emergencies', { status: text('status').notNull(), announcement: text('announcement'), dontBringList: text('dont_bring_list').array().notNull().default([]), + resourceDisputeThreshold: integer('resource_dispute_threshold'), createdAt: timestamp('created_at', { withTimezone: true }).notNull(), updatedAt: timestamp('updated_at', { withTimezone: true }) .notNull() diff --git a/apps/api/src/contexts/emergencies/infrastructure/emergencies.module.ts b/apps/api/src/contexts/emergencies/infrastructure/emergencies.module.ts index 9918fa3c..baa1a00b 100644 --- a/apps/api/src/contexts/emergencies/infrastructure/emergencies.module.ts +++ b/apps/api/src/contexts/emergencies/infrastructure/emergencies.module.ts @@ -10,6 +10,7 @@ import { PauseEmergency } from '../application/pause-emergency'; import { ResumeEmergency } from '../application/resume-emergency'; import { PublishAnnouncement } from '../application/publish-announcement'; import { CreateEmergencyFromTemplate } from '../application/create-emergency-from-template'; +import { SetEmergencyDisputeThreshold } from '../application/set-emergency-dispute-threshold'; import { EMERGENCY_REPOSITORY, EmergencyRepository, @@ -80,6 +81,13 @@ const createFromTemplateProvider = { ) => new CreateEmergencyFromTemplate(emergencyRepo, templateRepo), }; +const setDisputeThresholdProvider = { + provide: SetEmergencyDisputeThreshold, + inject: [EMERGENCY_REPOSITORY], + useFactory: (repo: EmergencyRepository) => + new SetEmergencyDisputeThreshold(repo), +}; + @Module({ imports: [DatabaseModule, IdentityModule, TemplatesModule], controllers: [EmergenciesController], @@ -93,6 +101,7 @@ const createFromTemplateProvider = { resumeEmergencyProvider, publishAnnouncementProvider, createFromTemplateProvider, + setDisputeThresholdProvider, ], }) export class EmergenciesModule {} diff --git a/apps/api/src/contexts/emergencies/infrastructure/http/dto.ts b/apps/api/src/contexts/emergencies/infrastructure/http/dto.ts index 3a7cb582..3c511d7a 100644 --- a/apps/api/src/contexts/emergencies/infrastructure/http/dto.ts +++ b/apps/api/src/contexts/emergencies/infrastructure/http/dto.ts @@ -1,6 +1,8 @@ import { + IsInt, IsNotEmpty, IsOptional, + IsPositive, IsString, IsUUID, Matches, @@ -126,3 +128,18 @@ export class CreateEmergencyFromTemplateDto { @MinLength(2) country!: string; } + +export class SetDisputeThresholdDto { + @ApiProperty({ + example: 5, + nullable: true, + description: + 'Número mínimo de reportantes distintos para marcar el punto como disputado. ' + + 'Null elimina el umbral específico y vuelve al global (RESOURCE_DISPUTE_THRESHOLD / 3).', + type: Number, + }) + @IsOptional() + @IsInt() + @IsPositive() + threshold!: number | null; +} diff --git a/apps/api/src/contexts/emergencies/infrastructure/http/emergencies.controller.ts b/apps/api/src/contexts/emergencies/infrastructure/http/emergencies.controller.ts index 360e0c1b..47c28d95 100644 --- a/apps/api/src/contexts/emergencies/infrastructure/http/emergencies.controller.ts +++ b/apps/api/src/contexts/emergencies/infrastructure/http/emergencies.controller.ts @@ -35,6 +35,7 @@ import { PauseEmergency } from '../../application/pause-emergency'; import { ResumeEmergency } from '../../application/resume-emergency'; import { PublishAnnouncement } from '../../application/publish-announcement'; import { CreateEmergencyFromTemplate } from '../../application/create-emergency-from-template'; +import { SetEmergencyDisputeThreshold } from '../../application/set-emergency-dispute-threshold'; import { CreateEmergencyDto, CreateEmergencyFromTemplateDto, @@ -42,6 +43,7 @@ import { EmergencyViewDto, MyEmergencyViewDto, PublishAnnouncementDto, + SetDisputeThresholdDto, } from './dto'; import { EmergencyExceptionFilter } from './emergency-exception.filter'; import { @@ -64,6 +66,7 @@ export class EmergenciesController { private readonly resume: ResumeEmergency, private readonly publishAnnouncement: PublishAnnouncement, private readonly createFromTemplate: CreateEmergencyFromTemplate, + private readonly setDisputeThreshold: SetEmergencyDisputeThreshold, ) {} @Post() @@ -240,4 +243,35 @@ export class EmergenciesController { message: dto.message, }); } + + @Put(':emergencyId/resource-dispute-threshold') + @HttpCode(204) + @UseGuards(JwtAuthGuard, PermissionGuard) + @RequirePermission('emergency:configure') + @ApiBearerAuth() + @ApiOperation({ + summary: + 'Fijar (o limpiar) el umbral de disputa por reportes para una emergencia', + }) + @ApiParam({ + name: 'emergencyId', + description: 'Emergency UUID', + format: 'uuid', + }) + @ApiNoContentResponse({ description: 'Umbral actualizado' }) + @ApiNotFoundResponse({ description: 'Emergency not found' }) + @ApiBadRequestResponse({ description: 'Invalid body' }) + @ApiUnauthorizedResponse({ description: 'Missing or invalid token' }) + @ApiForbiddenResponse({ + description: 'emergency:configure permission required', + }) + async setResourceDisputeThreshold( + @Param('emergencyId', ParseUUIDPipe) emergencyId: string, + @Body() dto: SetDisputeThresholdDto, + ): Promise { + await this.setDisputeThreshold.execute({ + emergencyId, + threshold: dto.threshold ?? null, + }); + } } diff --git a/apps/api/src/contexts/identity/domain/authorization/permission.ts b/apps/api/src/contexts/identity/domain/authorization/permission.ts index 32362bc8..dee507ff 100644 --- a/apps/api/src/contexts/identity/domain/authorization/permission.ts +++ b/apps/api/src/contexts/identity/domain/authorization/permission.ts @@ -17,6 +17,7 @@ export const PERMISSION_CATALOG = { 'resume', 'close', 'announce', + 'configure', 'read', ], resource: ['register', 'read', 'verify', 'close', 'edit'], diff --git a/apps/api/src/contexts/identity/domain/authorization/role-catalog.ts b/apps/api/src/contexts/identity/domain/authorization/role-catalog.ts index 262cc3b8..c5bfb921 100644 --- a/apps/api/src/contexts/identity/domain/authorization/role-catalog.ts +++ b/apps/api/src/contexts/identity/domain/authorization/role-catalog.ts @@ -39,6 +39,7 @@ export const ROLE_CATALOG: Record = { 'emergency:resume', 'emergency:close', 'emergency:announce', + 'emergency:configure', 'emergency:read', 'accreditation:grant', 'accreditation:revoke', @@ -81,6 +82,7 @@ export const ROLE_CATALOG: Record = { 'emergency:pause', 'emergency:resume', 'emergency:announce', + 'emergency:configure', 'role:grant', 'role:revoke', 'resource:read', diff --git a/apps/api/src/contexts/resources/application/report-resource-validity.spec.ts b/apps/api/src/contexts/resources/application/report-resource-validity.spec.ts index 703aaabd..ebc98132 100644 --- a/apps/api/src/contexts/resources/application/report-resource-validity.spec.ts +++ b/apps/api/src/contexts/resources/application/report-resource-validity.spec.ts @@ -58,8 +58,19 @@ describe('ReportResourceValidity', () => { return id; } - const useCase = (threshold?: number): ReportResourceValidity => - new ReportResourceValidity(resources, reports, bus, threshold); + const useCase = ( + threshold?: number, + emergencyThresholds?: { + getThreshold: (id: string) => Promise; + }, + ): ReportResourceValidity => + new ReportResourceValidity( + resources, + reports, + bus, + threshold, + emergencyThresholds, + ); const cmd = (resourceId: string, reporterUserId: string) => ({ resourceId, @@ -175,4 +186,28 @@ describe('ReportResourceValidity', () => { ResourceNotReportableError, ); }); + + it('usa el umbral por emergencia cuando está configurado', async () => { + const id = await seedPublished(); + // Umbral de 2 en vez del global de 3 + const thresholdReader = { + getThreshold: () => Promise.resolve(2 as number | null), + }; + const rep = useCase(3, thresholdReader); + await rep.execute(cmd(id, 'user-1')); + const res = await rep.execute(cmd(id, 'user-2')); + expect(res.disputed).toBe(true); + }); + + it('usa el umbral global cuando el umbral por emergencia es null', async () => { + const id = await seedPublished(); + const thresholdReader = { + getThreshold: () => Promise.resolve(null as number | null), + }; + const rep = useCase(3, thresholdReader); + await rep.execute(cmd(id, 'user-1')); + await rep.execute(cmd(id, 'user-2')); + const res = await rep.execute(cmd(id, 'user-3')); + expect(res.disputed).toBe(true); + }); }); diff --git a/apps/api/src/contexts/resources/application/report-resource-validity.ts b/apps/api/src/contexts/resources/application/report-resource-validity.ts index 19d6dca4..67f1303f 100644 --- a/apps/api/src/contexts/resources/application/report-resource-validity.ts +++ b/apps/api/src/contexts/resources/application/report-resource-validity.ts @@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto'; import { ResourceRepository } from '../domain/ports/resource.repository'; import { ResourceValidityReportRepository } from '../domain/ports/resource-validity-report.repository'; import { EventBus } from '../domain/ports/event-bus'; +import { EmergencyDisputeThresholdReader } from '../domain/ports/emergency-dispute-threshold-reader'; import { ResourceId } from '../domain/resource-id'; import { ResourceValidityReport, @@ -38,6 +39,7 @@ export class ReportResourceValidity { private readonly reports: ResourceValidityReportRepository, private readonly bus: EventBus, private readonly threshold: number = DEFAULT_DISPUTE_THRESHOLD, + private readonly emergencyThresholds?: EmergencyDisputeThresholdReader, ) {} async execute( @@ -87,7 +89,11 @@ export class ReportResourceValidity { Date.now() - FRESHNESS_WINDOW_DAYS * 24 * 60 * 60 * 1000, ); const freshDistinct = open.filter((r) => r.createdAt >= cutoff).length; - let disputed = freshDistinct >= this.threshold; + const emergencyThreshold = this.emergencyThresholds + ? await this.emergencyThresholds.getThreshold(resource.emergencyId.value) + : null; + const effectiveThreshold = emergencyThreshold ?? this.threshold; + let disputed = freshDistinct >= effectiveThreshold; if (disputed && !resource.disputed) { // Re-read so the flag decision uses the current persisted state, not the // snapshot loaded before this report was saved — two citizens crossing diff --git a/apps/api/src/contexts/resources/domain/ports/emergency-dispute-threshold-reader.ts b/apps/api/src/contexts/resources/domain/ports/emergency-dispute-threshold-reader.ts new file mode 100644 index 00000000..0ae89cff --- /dev/null +++ b/apps/api/src/contexts/resources/domain/ports/emergency-dispute-threshold-reader.ts @@ -0,0 +1,8 @@ +export const EMERGENCY_DISPUTE_THRESHOLD_READER = Symbol( + 'EmergencyDisputeThresholdReader', +); + +export interface EmergencyDisputeThresholdReader { + /** Returns the per-emergency threshold, or null when not configured. */ + getThreshold(emergencyId: string): Promise; +} diff --git a/apps/api/src/contexts/resources/infrastructure/resources.module.ts b/apps/api/src/contexts/resources/infrastructure/resources.module.ts index 18d5ba65..80d96581 100644 --- a/apps/api/src/contexts/resources/infrastructure/resources.module.ts +++ b/apps/api/src/contexts/resources/infrastructure/resources.module.ts @@ -36,6 +36,11 @@ import { import { EVENT_BUS, EventBus } from '../domain/ports/event-bus'; import { DrizzleResourceRepository } from './drizzle/drizzle-resource.repository'; import { DrizzleEmergencyStatusReader } from '../../../shared/drizzle-emergency-status-reader'; +import { DrizzleEmergencyDisputeThresholdReader } from '../../../shared/drizzle-emergency-dispute-threshold-reader'; +import { + EMERGENCY_DISPUTE_THRESHOLD_READER, + EmergencyDisputeThresholdReader, +} from '../domain/ports/emergency-dispute-threshold-reader'; import { DrizzleOrganizationAccreditationReader } from '../../../shared/drizzle-organization-accreditation-reader'; import { BullMqEventBus } from './bullmq-event-bus'; import { IdentityModule } from '../../identity/infrastructure/identity.module'; @@ -101,6 +106,13 @@ const emergencyStatusReaderProvider = { new DrizzleEmergencyStatusReader(db), }; +const emergencyDisputeThresholdReaderProvider = { + provide: EMERGENCY_DISPUTE_THRESHOLD_READER, + inject: [DB], + useFactory: (db: Db): EmergencyDisputeThresholdReader => + new DrizzleEmergencyDisputeThresholdReader(db), +}; + const busProvider = { provide: EVENT_BUS, inject: [EVENT_QUEUE], @@ -234,15 +246,27 @@ const validityReportRepositoryProvider = { const reportResourceValidityProvider = { provide: ReportResourceValidity, - inject: [RESOURCE_REPOSITORY, RESOURCE_VALIDITY_REPORT_REPOSITORY, EVENT_BUS], + inject: [ + RESOURCE_REPOSITORY, + RESOURCE_VALIDITY_REPORT_REPOSITORY, + EVENT_BUS, + EMERGENCY_DISPUTE_THRESHOLD_READER, + ], useFactory: ( repo: ResourceRepository, validityRepo: ResourceValidityReportRepository, bus: EventBus, + thresholdReader: EmergencyDisputeThresholdReader, ) => { const raw = Number(process.env.RESOURCE_DISPUTE_THRESHOLD); const threshold = Number.isFinite(raw) && raw > 0 ? raw : undefined; - return new ReportResourceValidity(repo, validityRepo, bus, threshold); + return new ReportResourceValidity( + repo, + validityRepo, + bus, + threshold, + thresholdReader, + ); }, }; @@ -324,6 +348,7 @@ const recordInventoryEntryProvider = { eventQueueProvider, resourceRepositoryProvider, emergencyStatusReaderProvider, + emergencyDisputeThresholdReaderProvider, organizationAccreditationReaderProvider, membershipReaderProvider, busProvider, diff --git a/apps/api/src/shared/drizzle-emergency-dispute-threshold-reader.ts b/apps/api/src/shared/drizzle-emergency-dispute-threshold-reader.ts new file mode 100644 index 00000000..b2dc7e47 --- /dev/null +++ b/apps/api/src/shared/drizzle-emergency-dispute-threshold-reader.ts @@ -0,0 +1,24 @@ +import { eq } from 'drizzle-orm'; +import { Db } from './db'; +import { emergenciesTable } from '../contexts/emergencies/infrastructure/drizzle/schema'; + +/** + * Shared Drizzle adapter — reads resource_dispute_threshold from emergencies. + * + * Used by the resources context to apply a per-emergency dispute threshold + * instead of the global constant. Accepted cross-context infra coupling, + * following the same pattern as DrizzleEmergencyStatusReader. + */ +export class DrizzleEmergencyDisputeThresholdReader { + constructor(private readonly db: Db) {} + + async getThreshold(emergencyId: string): Promise { + const rows = await this.db + .select({ + resourceDisputeThreshold: emergenciesTable.resourceDisputeThreshold, + }) + .from(emergenciesTable) + .where(eq(emergenciesTable.id, emergencyId)); + return rows[0]?.resourceDisputeThreshold ?? null; + } +} diff --git a/packages/api-client/src/schema.ts b/packages/api-client/src/schema.ts index 92259b9a..06dc60ee 100644 --- a/packages/api-client/src/schema.ts +++ b/packages/api-client/src/schema.ts @@ -813,6 +813,23 @@ export interface paths { patch?: never; trace?: never; }; + "/emergencies/{emergencyId}/resource-dispute-threshold": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Fijar (o limpiar) el umbral de disputa por reportes para una emergencia */ + put: operations["EmergenciesController_setResourceDisputeThreshold"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/emergencies/{emergencyId}/needs": { parameters: { query?: never; @@ -3676,6 +3693,13 @@ export interface components { /** @example Se suspenden las operaciones de rescate hasta nuevo aviso. */ message: string; }; + SetDisputeThresholdDto: { + /** + * @description Número mínimo de reportantes distintos para marcar el punto como disputado. Null elimina el umbral específico y vuelve al global (RESOURCE_DISPUTE_THRESHOLD / 3). + * @example 5 + */ + threshold: number | null; + }; NeedLocationDto: { /** @example 123 Main Street, Caracas, Venezuela */ address: string; @@ -7487,6 +7511,59 @@ export interface operations { }; }; }; + EmergenciesController_setResourceDisputeThreshold: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Emergency UUID */ + emergencyId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SetDisputeThresholdDto"]; + }; + }; + responses: { + /** @description Umbral actualizado */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Invalid body */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Missing or invalid token */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description emergency:configure permission required */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Emergency not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; NeedsController_create: { parameters: { query?: never;