diff --git a/.gitignore b/.gitignore index c7b5d6df..6f125eed 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ apps/api/uploads/ # Local git worktrees for parallel agent work — never commit .worktrees/ +.claude/worktrees/ diff --git a/apps/api/drizzle/0060_emergency_auto_hide_on_dispute.sql b/apps/api/drizzle/0060_emergency_auto_hide_on_dispute.sql new file mode 100644 index 00000000..9a59580c --- /dev/null +++ b/apps/api/drizzle/0060_emergency_auto_hide_on_dispute.sql @@ -0,0 +1,5 @@ +-- Auto-ocultado opcional como política por emergencia (#171). +-- Off por defecto: no cambia el comportamiento de ninguna emergencia existente +-- hasta que un coordinador la active explícitamente. +ALTER TABLE emergencies + ADD COLUMN IF NOT EXISTS auto_hide_on_dispute boolean NOT NULL DEFAULT false; diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 960589b7..867bd5ea 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -2932,6 +2932,59 @@ ] } }, + "/emergencies/{emergencyId}/auto-hide-on-dispute": { + "put": { + "operationId": "EmergenciesController_setEmergencyAutoHideOnDispute", + "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/SetAutoHideOnDisputeDto" + } + } + } + }, + "responses": { + "204": { + "description": "Política actualizada" + }, + "400": { + "description": "Invalid body" + }, + "401": { + "description": "Missing or invalid token" + }, + "403": { + "description": "emergency:configure permission required" + }, + "404": { + "description": "Emergency not found" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Activar o desactivar la política de auto-ocultado por disputa (#171)", + "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).", @@ -12026,6 +12079,11 @@ "example": 5, "nullable": true, "description": "Umbral de disputa configurado para esta emergencia, o null cuando usa el global. Solo se expone en la vista autenticada." + }, + "autoHideOnDispute": { + "type": "boolean", + "example": false, + "description": "Política opt-in (#171): si está activa, un punto disputado que alcanza el umbral se cierra automáticamente (misma transición que \"confirmar cierre\"); si no, el comportamiento actual (visible con badge, confirma un coordinador). Off por defecto. Solo se expone en la vista autenticada." } }, "required": [ @@ -12038,7 +12096,8 @@ "dontBringList", "updatedAt", "roleIds", - "resourceDisputeThreshold" + "resourceDisputeThreshold", + "autoHideOnDispute" ] }, "CreateEmergencyFromTemplateDto": { @@ -12100,6 +12159,19 @@ "threshold" ] }, + "SetAutoHideOnDisputeDto": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "example": true, + "description": "Activa (true) o desactiva (false) la política de auto-ocultado (#171)." + } + }, + "required": [ + "enabled" + ] + }, "NeedLocationDto": { "type": "object", "properties": { diff --git a/apps/api/src/contexts/emergencies/application/list-my-emergencies.spec.ts b/apps/api/src/contexts/emergencies/application/list-my-emergencies.spec.ts index a95ce2c0..9126d65b 100644 --- a/apps/api/src/contexts/emergencies/application/list-my-emergencies.spec.ts +++ b/apps/api/src/contexts/emergencies/application/list-my-emergencies.spec.ts @@ -28,6 +28,7 @@ describe('ListMyEmergencies', () => { announcement: null, dontBringList: [], resourceDisputeThreshold: 7, + autoHideOnDispute: true, createdAt: new Date(), updatedAt: new Date(), }); @@ -42,6 +43,7 @@ describe('ListMyEmergencies', () => { announcement: null, dontBringList: [], resourceDisputeThreshold: null, + autoHideOnDispute: false, createdAt: new Date(), updatedAt: new Date(), }); @@ -61,6 +63,7 @@ describe('ListMyEmergencies', () => { expect(views[0].slug).toBe('terremoto-venezuela-2026'); expect(views[0].roleIds).toEqual(['emergency_verifier']); expect(views[0].resourceDisputeThreshold).toBe(7); + expect(views[0].autoHideOnDispute).toBe(true); }); it('exposes a null dispute threshold when the emergency uses the global default', async () => { @@ -72,6 +75,15 @@ describe('ListMyEmergencies', () => { expect(views[0].resourceDisputeThreshold).toBeNull(); }); + it('exposes the auto-hide-on-dispute policy (#171), off by default', async () => { + const repo = await seed(); + const useCase = new ListMyEmergencies(repo); + + const views = await useCase.execute([grant(PAUSED_ID)]); + + expect(views[0].autoHideOnDispute).toBe(false); + }); + it('includes PAUSED emergencies (unlike listActive)', async () => { const repo = await seed(); const useCase = new ListMyEmergencies(repo); diff --git a/apps/api/src/contexts/emergencies/application/list-my-emergencies.ts b/apps/api/src/contexts/emergencies/application/list-my-emergencies.ts index 58113b71..3f474e69 100644 --- a/apps/api/src/contexts/emergencies/application/list-my-emergencies.ts +++ b/apps/api/src/contexts/emergencies/application/list-my-emergencies.ts @@ -11,6 +11,8 @@ export interface MyEmergencyView extends EmergencyView { roleIds: string[]; /** Per-emergency dispute threshold, or null when it uses the global default. */ resourceDisputeThreshold: number | null; + /** Opt-in policy (#171): auto-resolve a disputed resource on threshold, default off. */ + autoHideOnDispute: boolean; } /** @@ -66,6 +68,7 @@ export class ListMyEmergencies { ...toEmergencyView(e), roleIds: roleIdsByEmergency.get(e.id.value) ?? [], resourceDisputeThreshold: e.resourceDisputeThreshold, + autoHideOnDispute: e.autoHideOnDispute, })); } } diff --git a/apps/api/src/contexts/emergencies/application/set-emergency-auto-hide-on-dispute.spec.ts b/apps/api/src/contexts/emergencies/application/set-emergency-auto-hide-on-dispute.spec.ts new file mode 100644 index 00000000..7a671ae9 --- /dev/null +++ b/apps/api/src/contexts/emergencies/application/set-emergency-auto-hide-on-dispute.spec.ts @@ -0,0 +1,81 @@ +import { SetEmergencyAutoHideOnDispute } from './set-emergency-auto-hide-on-dispute'; +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, + autoHideOnDispute: false, + 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('SetEmergencyAutoHideOnDispute', () => { + it('turns the policy on (#171)', async () => { + const emergency = Emergency.fromSnapshot(SNAP); + const { repo, saveMock, saved } = makeRepo(emergency); + + await new SetEmergencyAutoHideOnDispute(repo).execute({ + emergencyId: SNAP.id, + enabled: true, + }); + + expect(saveMock).toHaveBeenCalledTimes(1); + expect(saved()!.autoHideOnDispute).toBe(true); + }); + + it('turns the policy back off', async () => { + const emergency = Emergency.fromSnapshot({ + ...SNAP, + autoHideOnDispute: true, + }); + const { repo, saved } = makeRepo(emergency); + + await new SetEmergencyAutoHideOnDispute(repo).execute({ + emergencyId: SNAP.id, + enabled: false, + }); + + expect(saved()!.autoHideOnDispute).toBe(false); + }); + + it('lanza EmergencyNotFoundError si la emergencia no existe', async () => { + const { repo, saveMock } = makeRepo(null); + + await expect( + new SetEmergencyAutoHideOnDispute(repo).execute({ + emergencyId: SNAP.id, + enabled: true, + }), + ).rejects.toBeInstanceOf(EmergencyNotFoundError); + expect(saveMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/contexts/emergencies/application/set-emergency-auto-hide-on-dispute.ts b/apps/api/src/contexts/emergencies/application/set-emergency-auto-hide-on-dispute.ts new file mode 100644 index 00000000..10f5a0cb --- /dev/null +++ b/apps/api/src/contexts/emergencies/application/set-emergency-auto-hide-on-dispute.ts @@ -0,0 +1,27 @@ +import { EmergencyRepository } from '../domain/ports/emergency.repository'; +import { EmergencyId } from '../../../shared/domain/emergency-id'; +import { EmergencyNotFoundError } from './emergency-not-found.error'; + +export interface SetEmergencyAutoHideOnDisputeCommand { + emergencyId: string; + enabled: boolean; +} + +/** + * Coordinator toggle (#171) for the opt-in auto-hide-on-dispute policy: when + * enabled, a `ResourceDisputed` handler in the resources context resolves the + * dispute automatically (same transition as a human "confirm cierre") instead + * of leaving the point visible with a badge for a human to confirm. Off by + * default; this use case is the only way to turn it on or off. + */ +export class SetEmergencyAutoHideOnDispute { + constructor(private readonly repo: EmergencyRepository) {} + + async execute(cmd: SetEmergencyAutoHideOnDisputeCommand): Promise { + const id = EmergencyId.fromString(cmd.emergencyId); + const emergency = await this.repo.findById(id); + if (!emergency) throw new EmergencyNotFoundError(cmd.emergencyId); + emergency.setAutoHideOnDispute(cmd.enabled); + await this.repo.save(emergency); + } +} 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 index d9afcaff..9b5fa84c 100644 --- 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 @@ -14,6 +14,7 @@ const SNAP = { announcement: null, dontBringList: [], resourceDisputeThreshold: null, + autoHideOnDispute: false, createdAt: new Date('2026-01-01T00:00:00Z'), updatedAt: new Date('2026-01-01T00:00:00Z'), }; diff --git a/apps/api/src/contexts/emergencies/domain/emergency.spec.ts b/apps/api/src/contexts/emergencies/domain/emergency.spec.ts index 034f1d2e..e2ac75b7 100644 --- a/apps/api/src/contexts/emergencies/domain/emergency.spec.ts +++ b/apps/api/src/contexts/emergencies/domain/emergency.spec.ts @@ -164,11 +164,13 @@ describe('Emergency', () => { const e = makeEmergency(); e.pause(); e.publishAnnouncement('Round-trip test'); + e.setAutoHideOnDispute(true); const snap = e.toSnapshot(); expect(snap.status).toBe(EmergencyStatus.Paused); expect(snap.announcement).toBe('Round-trip test'); expect(snap.updatedAt).toBeInstanceOf(Date); + expect(snap.autoHideOnDispute).toBe(true); const restored = Emergency.fromSnapshot(snap); expect(restored.id.equals(e.id)).toBe(true); @@ -178,5 +180,27 @@ describe('Emergency', () => { expect(restored.updatedAt.toISOString()).toBe(e.updatedAt.toISOString()); expect(restored.country).toBe('TR'); expect(restored.createdAt.toISOString()).toBe(e.createdAt.toISOString()); + expect(restored.autoHideOnDispute).toBe(true); + }); + + describe('setAutoHideOnDispute()', () => { + it('creates with the policy off by default (#171)', () => { + expect(makeEmergency().autoHideOnDispute).toBe(false); + }); + + it('turns the policy on and updates updatedAt', () => { + const e = makeEmergency(); + const before = new Date(); + e.setAutoHideOnDispute(true); + expect(e.autoHideOnDispute).toBe(true); + expect(e.updatedAt.getTime()).toBeGreaterThanOrEqual(before.getTime()); + }); + + it('turns the policy back off', () => { + const e = makeEmergency(); + e.setAutoHideOnDispute(true); + e.setAutoHideOnDispute(false); + expect(e.autoHideOnDispute).toBe(false); + }); }); }); diff --git a/apps/api/src/contexts/emergencies/domain/emergency.ts b/apps/api/src/contexts/emergencies/domain/emergency.ts index e31b2fe7..84651dad 100644 --- a/apps/api/src/contexts/emergencies/domain/emergency.ts +++ b/apps/api/src/contexts/emergencies/domain/emergency.ts @@ -25,6 +25,8 @@ export interface EmergencySnapshot { announcement: string | null; dontBringList: string[]; resourceDisputeThreshold: number | null; + /** Opt-in policy (#171): auto-resolve a disputed resource on threshold, default off. */ + autoHideOnDispute: boolean; createdAt: Date; updatedAt: Date; } @@ -39,6 +41,7 @@ export class Emergency { private _announcement: string | null, private _dontBringList: string[], private _resourceDisputeThreshold: number | null, + private _autoHideOnDispute: boolean, public readonly createdAt: Date, private _updatedAt: Date, ) {} @@ -54,6 +57,7 @@ export class Emergency { props.announcement ?? null, props.dontBringList ?? [], null, + false, now, now, ); @@ -69,6 +73,7 @@ export class Emergency { snap.announcement, snap.dontBringList, snap.resourceDisputeThreshold, + snap.autoHideOnDispute, snap.createdAt, snap.updatedAt, ); @@ -90,6 +95,10 @@ export class Emergency { return this._resourceDisputeThreshold; } + get autoHideOnDispute(): boolean { + return this._autoHideOnDispute; + } + get updatedAt(): Date { return this._updatedAt; } @@ -133,6 +142,18 @@ export class Emergency { this._updatedAt = new Date(); } + /** + * Opt-in per-emergency policy (#171): when enabled, a disputed resource that + * reaches the dispute threshold is closed automatically (same transition a + * coordinator's "confirm cierre" performs) instead of just staying visible + * with a badge. Off by default — must not change behavior for any emergency + * unless a coordinator explicitly turns it on. + */ + setAutoHideOnDispute(enabled: boolean): void { + this._autoHideOnDispute = enabled; + this._updatedAt = new Date(); + } + toSnapshot(): EmergencySnapshot { return { id: this.id.value, @@ -143,6 +164,7 @@ export class Emergency { announcement: this._announcement, dontBringList: this._dontBringList, resourceDisputeThreshold: this._resourceDisputeThreshold, + autoHideOnDispute: this._autoHideOnDispute, 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 e13aea85..94832ca0 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 @@ -19,6 +19,7 @@ function rowToSnapshot(row: Row): EmergencySnapshot { announcement: row.announcement ?? null, dontBringList: row.dontBringList, resourceDisputeThreshold: row.resourceDisputeThreshold ?? null, + autoHideOnDispute: row.autoHideOnDispute ?? false, createdAt: row.createdAt, updatedAt: row.updatedAt, }; @@ -39,6 +40,7 @@ export class DrizzleEmergencyRepository implements EmergencyRepository { status: s.status, announcement: s.announcement, dontBringList: s.dontBringList, + autoHideOnDispute: s.autoHideOnDispute, createdAt: s.createdAt, updatedAt: s.updatedAt, }) @@ -51,6 +53,7 @@ export class DrizzleEmergencyRepository implements EmergencyRepository { announcement: s.announcement, dontBringList: s.dontBringList, resourceDisputeThreshold: s.resourceDisputeThreshold, + autoHideOnDispute: s.autoHideOnDispute, 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 2fa3e90b..153f5558 100644 --- a/apps/api/src/contexts/emergencies/infrastructure/drizzle/schema.ts +++ b/apps/api/src/contexts/emergencies/infrastructure/drizzle/schema.ts @@ -1,4 +1,11 @@ -import { pgTable, uuid, text, timestamp, integer } from 'drizzle-orm/pg-core'; +import { + pgTable, + uuid, + text, + timestamp, + integer, + boolean, +} from 'drizzle-orm/pg-core'; export const emergenciesTable = pgTable('emergencies', { id: uuid('id').primaryKey(), @@ -9,6 +16,7 @@ export const emergenciesTable = pgTable('emergencies', { announcement: text('announcement'), dontBringList: text('dont_bring_list').array().notNull().default([]), resourceDisputeThreshold: integer('resource_dispute_threshold'), + autoHideOnDispute: boolean('auto_hide_on_dispute').notNull().default(false), 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 baa1a00b..2bd4c4fb 100644 --- a/apps/api/src/contexts/emergencies/infrastructure/emergencies.module.ts +++ b/apps/api/src/contexts/emergencies/infrastructure/emergencies.module.ts @@ -11,6 +11,7 @@ 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 { SetEmergencyAutoHideOnDispute } from '../application/set-emergency-auto-hide-on-dispute'; import { EMERGENCY_REPOSITORY, EmergencyRepository, @@ -88,6 +89,13 @@ const setDisputeThresholdProvider = { new SetEmergencyDisputeThreshold(repo), }; +const setAutoHideOnDisputeProvider = { + provide: SetEmergencyAutoHideOnDispute, + inject: [EMERGENCY_REPOSITORY], + useFactory: (repo: EmergencyRepository) => + new SetEmergencyAutoHideOnDispute(repo), +}; + @Module({ imports: [DatabaseModule, IdentityModule, TemplatesModule], controllers: [EmergenciesController], @@ -102,6 +110,7 @@ const setDisputeThresholdProvider = { publishAnnouncementProvider, createFromTemplateProvider, setDisputeThresholdProvider, + setAutoHideOnDisputeProvider, ], }) 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 229c9c2b..1b6201bb 100644 --- a/apps/api/src/contexts/emergencies/infrastructure/http/dto.ts +++ b/apps/api/src/contexts/emergencies/infrastructure/http/dto.ts @@ -1,4 +1,5 @@ import { + IsBoolean, IsInt, IsNotEmpty, IsOptional, @@ -103,6 +104,16 @@ export class MyEmergencyViewDto extends EmergencyViewDto { 'el global. Solo se expone en la vista autenticada.', }) resourceDisputeThreshold!: number | null; + + @ApiProperty({ + example: false, + description: + 'Política opt-in (#171): si está activa, un punto disputado que alcanza ' + + 'el umbral se cierra automáticamente (misma transición que "confirmar ' + + 'cierre"); si no, el comportamiento actual (visible con badge, confirma ' + + 'un coordinador). Off por defecto. Solo se expone en la vista autenticada.', + }) + autoHideOnDispute!: boolean; } export class PublishAnnouncementDto { @@ -164,3 +175,13 @@ export class SetDisputeThresholdDto { @Max(MAX_RESOURCE_DISPUTE_THRESHOLD) threshold!: number | null; } + +export class SetAutoHideOnDisputeDto { + @ApiProperty({ + example: true, + description: + 'Activa (true) o desactiva (false) la política de auto-ocultado (#171).', + }) + @IsBoolean() + enabled!: boolean; +} 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 9db75a7b..bf69f3fe 100644 --- a/apps/api/src/contexts/emergencies/infrastructure/http/emergencies.controller.ts +++ b/apps/api/src/contexts/emergencies/infrastructure/http/emergencies.controller.ts @@ -36,6 +36,7 @@ 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 { SetEmergencyAutoHideOnDispute } from '../../application/set-emergency-auto-hide-on-dispute'; import { CreateEmergencyDto, CreateEmergencyFromTemplateDto, @@ -44,6 +45,7 @@ import { MyEmergencyViewDto, PublishAnnouncementDto, SetDisputeThresholdDto, + SetAutoHideOnDisputeDto, } from './dto'; import { EmergencyExceptionFilter } from './emergency-exception.filter'; import { @@ -67,6 +69,7 @@ export class EmergenciesController { private readonly publishAnnouncement: PublishAnnouncement, private readonly createFromTemplate: CreateEmergencyFromTemplate, private readonly setDisputeThreshold: SetEmergencyDisputeThreshold, + private readonly setAutoHideOnDispute: SetEmergencyAutoHideOnDispute, ) {} @Post() @@ -274,4 +277,35 @@ export class EmergenciesController { threshold: dto.threshold, }); } + + @Put(':emergencyId/auto-hide-on-dispute') + @HttpCode(204) + @UseGuards(JwtAuthGuard, PermissionGuard) + @RequirePermission('emergency:configure') + @ApiBearerAuth() + @ApiOperation({ + summary: + 'Activar o desactivar la política de auto-ocultado por disputa (#171)', + }) + @ApiParam({ + name: 'emergencyId', + description: 'Emergency UUID', + format: 'uuid', + }) + @ApiNoContentResponse({ description: 'Política actualizada' }) + @ApiNotFoundResponse({ description: 'Emergency not found' }) + @ApiBadRequestResponse({ description: 'Invalid body' }) + @ApiUnauthorizedResponse({ description: 'Missing or invalid token' }) + @ApiForbiddenResponse({ + description: 'emergency:configure permission required', + }) + async setEmergencyAutoHideOnDispute( + @Param('emergencyId', ParseUUIDPipe) emergencyId: string, + @Body() dto: SetAutoHideOnDisputeDto, + ): Promise { + await this.setAutoHideOnDispute.execute({ + emergencyId, + enabled: dto.enabled, + }); + } } diff --git a/apps/api/src/contexts/emergencies/infrastructure/http/set-auto-hide-on-dispute.dto.spec.ts b/apps/api/src/contexts/emergencies/infrastructure/http/set-auto-hide-on-dispute.dto.spec.ts new file mode 100644 index 00000000..5fc15dcb --- /dev/null +++ b/apps/api/src/contexts/emergencies/infrastructure/http/set-auto-hide-on-dispute.dto.spec.ts @@ -0,0 +1,43 @@ +import { ValidationPipe, BadRequestException } from '@nestjs/common'; +import { SetAutoHideOnDisputeDto } from './dto'; + +// Mirror the global ValidationPipe from configure-http-app.ts (see the +// analogous set-dispute-threshold.dto.spec.ts for the same pattern). +const pipe = new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, +}); +const meta = { + type: 'body' as const, + metatype: SetAutoHideOnDisputeDto, + data: '', +}; + +const run = (payload: unknown) => pipe.transform(payload, meta); + +describe('SetAutoHideOnDisputeDto validation (#171)', () => { + it('accepts enabled: true', async () => { + await expect(run({ enabled: true })).resolves.toEqual({ enabled: true }); + }); + + it('accepts enabled: false', async () => { + await expect(run({ enabled: false })).resolves.toEqual({ enabled: false }); + }); + + it('rejects a missing body — the field is required', async () => { + await expect(run({})).rejects.toBeInstanceOf(BadRequestException); + }); + + it.each([1, 0, 'true', null, undefined])('rejects %p', async (bad) => { + await expect(run({ enabled: bad })).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it('rejects unknown properties', async () => { + await expect(run({ enabled: true, extra: 1 })).rejects.toBeInstanceOf( + BadRequestException, + ); + }); +}); diff --git a/apps/api/src/contexts/resources/application/auto-hide-disputed-resource.spec.ts b/apps/api/src/contexts/resources/application/auto-hide-disputed-resource.spec.ts new file mode 100644 index 00000000..c5191b67 --- /dev/null +++ b/apps/api/src/contexts/resources/application/auto-hide-disputed-resource.spec.ts @@ -0,0 +1,155 @@ +import { AutoHideDisputedResource } from './auto-hide-disputed-resource'; +import { ResolveResourceDispute } from './resolve-resource-dispute'; +import { ReportResourceValidity } from './report-resource-validity'; +import { RegisterResource } from './register-resource'; +import { VerifyResource } from './verify-resource'; +import { PublishResource } from './publish-resource'; +import { InMemoryResourceRepository } from '../infrastructure/in-memory-resource.repository'; +import { InMemoryResourceValidityReportRepository } from '../infrastructure/in-memory-resource-validity-report.repository'; +import { FakeEventBus } from '../infrastructure/fake-event-bus'; +import { ResourceId } from '../domain/resource-id'; +import { PublicStatus, ResourceType } from '../domain/resource-enums'; +import { ValidityReason } from '../domain/resource-validity-report'; +import { ResourceEmergencyStatusReader } from '../domain/ports/emergency-status-reader'; +import { EmergencyAutoHideOnDisputeReader } from '../domain/ports/emergency-auto-hide-on-dispute-reader'; +import { AuditTrail, SystemAuditEntry } from '../domain/ports/audit-trail'; + +const EM = '11111111-1111-4111-8111-111111111111'; +const OWNER = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; +const activeReader: ResourceEmergencyStatusReader = { + getStatus: () => Promise.resolve('active'), +}; + +function policyReader(enabled: boolean): EmergencyAutoHideOnDisputeReader { + return { getAutoHideOnDispute: () => Promise.resolve(enabled) }; +} + +class RecordingAuditTrail implements AuditTrail { + readonly recorded: SystemAuditEntry[] = []; + recordSystemAction(entry: SystemAuditEntry): Promise { + this.recorded.push(entry); + return Promise.resolve(); + } +} + +describe('AutoHideDisputedResource (#171)', () => { + let resources: InMemoryResourceRepository; + let reports: InMemoryResourceValidityReportRepository; + let bus: FakeEventBus; + + beforeEach(() => { + resources = new InMemoryResourceRepository(); + reports = new InMemoryResourceValidityReportRepository(); + bus = new FakeEventBus(); + }); + + async function seedDisputed(): Promise { + const { id } = await new RegisterResource( + resources, + bus, + activeReader, + ).execute({ + emergencyId: EM, + type: ResourceType.CollectionPoint, + name: 'Acopio Centro', + description: null, + location: { address: 'Caracas', latitude: 10.48, longitude: -66.9 }, + ownerUserId: OWNER, + }); + await new VerifyResource(resources, bus, { + isAccredited: () => Promise.resolve(false), + }).execute({ resourceId: id, coordinatorId: 'coord' }); + await new PublishResource(resources, bus).execute({ resourceId: id }); + + const rep = new ReportResourceValidity(resources, reports, bus, 3); + for (const user of ['user-1', 'user-2', 'user-3']) { + await rep.execute({ + resourceId: id, + reporterUserId: user, + reason: ValidityReason.Closed, + }); + } + return id; + } + + it('does nothing when the policy is off (default MVP behavior unchanged)', async () => { + const id = await seedDisputed(); + const audit = new RecordingAuditTrail(); + const resolve = new ResolveResourceDispute(resources, reports, bus); + const useCase = new AutoHideDisputedResource( + policyReader(false), + resolve, + audit, + ); + + await useCase.execute({ resourceId: id, emergencyId: EM }); + + const r = await resources.findById(ResourceId.fromString(id)); + expect(r!.disputed).toBe(true); + expect(r!.publicStatus).toBe(PublicStatus.Active); + expect(audit.recorded).toEqual([]); + }); + + it('closes the resource like a human confirm_closed when the policy is on', async () => { + const id = await seedDisputed(); + const audit = new RecordingAuditTrail(); + const resolve = new ResolveResourceDispute(resources, reports, bus); + const useCase = new AutoHideDisputedResource( + policyReader(true), + resolve, + audit, + ); + + await useCase.execute({ resourceId: id, emergencyId: EM }); + + const r = await resources.findById(ResourceId.fromString(id)); + expect(r!.publicStatus).toBe(PublicStatus.Closed); + expect(r!.disputed).toBe(false); + }); + + it('records a system-attributed audit entry when it auto-hides', async () => { + const id = await seedDisputed(); + const audit = new RecordingAuditTrail(); + const resolve = new ResolveResourceDispute(resources, reports, bus); + const useCase = new AutoHideDisputedResource( + policyReader(true), + resolve, + audit, + ); + + await useCase.execute({ resourceId: id, emergencyId: EM }); + + expect(audit.recorded).toHaveLength(1); + const entry = audit.recorded[0]; + expect(entry.entityType).toBe('resource'); + expect(entry.entityId).toBe(id); + expect(entry.emergencyId).toBe(EM); + expect(entry.targetStatus).toBe(PublicStatus.Closed); + expect(entry.changes.some((c) => c.field === 'publicStatus')).toBe(true); + }); + + it('is a no-op when a human already resolved the dispute (idempotent)', async () => { + const id = await seedDisputed(); + const audit = new RecordingAuditTrail(); + const resolve = new ResolveResourceDispute(resources, reports, bus); + // Human resolves first (dismiss) — the resource is no longer disputed. + await resolve.execute({ + resourceId: id, + coordinatorId: 'coord-1', + resolution: 'dismiss', + }); + + const useCase = new AutoHideDisputedResource( + policyReader(true), + resolve, + audit, + ); + await expect( + useCase.execute({ resourceId: id, emergencyId: EM }), + ).resolves.toBeUndefined(); + + const r = await resources.findById(ResourceId.fromString(id)); + expect(r!.publicStatus).toBe(PublicStatus.Active); + expect(audit.recorded).toEqual([]); + }); +}); diff --git a/apps/api/src/contexts/resources/application/auto-hide-disputed-resource.ts b/apps/api/src/contexts/resources/application/auto-hide-disputed-resource.ts new file mode 100644 index 00000000..71069552 --- /dev/null +++ b/apps/api/src/contexts/resources/application/auto-hide-disputed-resource.ts @@ -0,0 +1,61 @@ +import { ResolveResourceDispute } from './resolve-resource-dispute'; +import { ResourceNotDisputedError } from '../domain/resource-errors'; +import { EmergencyAutoHideOnDisputeReader } from '../domain/ports/emergency-auto-hide-on-dispute-reader'; +import { AuditTrail } from '../domain/ports/audit-trail'; + +/** Attributed in the audit trail instead of a human coordinator id. */ +export const SYSTEM_ACTOR_ID = 'system'; + +export interface AutoHideDisputedResourceCommand { + resourceId: string; + emergencyId: string; +} + +/** + * Reacts to `ResourceDisputed` when the owning emergency has opted in to the + * auto-hide-on-dispute policy (#171): resolves the dispute exactly like a + * coordinator's "confirm cierre" — reusing {@link ResolveResourceDispute}'s + * `confirm_closed` path, so it is the *same* state transition (same Closed + * status, same report resolution), just automatic and attributed to "system". + * + * No-ops when: + * - the policy is off for this emergency (default — MVP behavior unchanged); + * - the resource is no longer disputed (a human already resolved it in the + * meantime — this handler may be invoked once per `ResourceDisputed` event, + * so this keeps it idempotent instead of throwing). + */ +export class AutoHideDisputedResource { + constructor( + private readonly policy: EmergencyAutoHideOnDisputeReader, + private readonly resolve: ResolveResourceDispute, + private readonly audit: AuditTrail, + ) {} + + async execute(cmd: AutoHideDisputedResourceCommand): Promise { + const enabled = await this.policy.getAutoHideOnDispute(cmd.emergencyId); + if (!enabled) return; + + let result; + try { + result = await this.resolve.execute({ + resourceId: cmd.resourceId, + coordinatorId: SYSTEM_ACTOR_ID, + resolution: 'confirm_closed', + }); + } catch (err) { + if (err instanceof ResourceNotDisputedError) return; + throw err; + } + + await this.audit.recordSystemAction({ + action: 'resource.auto_hide_on_dispute', + entityType: 'resource', + entityId: cmd.resourceId, + emergencyId: result.emergencyId, + targetStatus: result.targetStatus, + changes: result.changes, + reason: + 'Ocultado automático: política autoHideOnDispute activa y umbral de disputa alcanzado (#171)', + }); + } +} diff --git a/apps/api/src/contexts/resources/domain/ports/audit-trail.ts b/apps/api/src/contexts/resources/domain/ports/audit-trail.ts new file mode 100644 index 00000000..476cfab8 --- /dev/null +++ b/apps/api/src/contexts/resources/domain/ports/audit-trail.ts @@ -0,0 +1,28 @@ +export const AUDIT_TRAIL = Symbol('AuditTrail'); + +export interface AuditTrailFieldChange { + field: string; + before: unknown; + after: unknown; +} + +export interface SystemAuditEntry { + action: string; + entityType: string; + entityId: string; + emergencyId: string; + targetStatus: string | null; + changes: AuditTrailFieldChange[]; + reason: string; +} + +/** + * Records a system-attributed mutation in the activity trail — the equivalent + * of the audit context's `AuditInterceptor` for actions that happen off an + * HTTP request (e.g. an event-handler-driven automatic transition, #171). A + * dedicated port owned by the resources context (DIP): the audit context + * stays unaware of who calls it. + */ +export interface AuditTrail { + recordSystemAction(entry: SystemAuditEntry): Promise; +} diff --git a/apps/api/src/contexts/resources/domain/ports/emergency-auto-hide-on-dispute-reader.ts b/apps/api/src/contexts/resources/domain/ports/emergency-auto-hide-on-dispute-reader.ts new file mode 100644 index 00000000..cd8e7fac --- /dev/null +++ b/apps/api/src/contexts/resources/domain/ports/emergency-auto-hide-on-dispute-reader.ts @@ -0,0 +1,15 @@ +export const EMERGENCY_AUTO_HIDE_ON_DISPUTE_READER = Symbol( + 'EmergencyAutoHideOnDisputeReader', +); + +/** + * Reads the per-emergency opt-in auto-hide-on-dispute policy (#171): when + * enabled, `resource.disputed` is resolved automatically on threshold instead + * of waiting for a coordinator. Mirrors {@link EmergencyDisputeThresholdReader} + * — a dedicated port owned by the resources context (DIP), backed by a shared + * adapter that reads the emergencies table directly. + */ +export interface EmergencyAutoHideOnDisputeReader { + /** Returns whether the policy is on for this emergency (off by default). */ + getAutoHideOnDispute(emergencyId: string): Promise; +} diff --git a/apps/api/src/contexts/resources/infrastructure/resource-disputed.handler.spec.ts b/apps/api/src/contexts/resources/infrastructure/resource-disputed.handler.spec.ts new file mode 100644 index 00000000..e5b45da1 --- /dev/null +++ b/apps/api/src/contexts/resources/infrastructure/resource-disputed.handler.spec.ts @@ -0,0 +1,35 @@ +import { resourceDisputedHandler } from './resource-disputed.handler'; +import { AutoHideDisputedResource } from '../application/auto-hide-disputed-resource'; +import { DomainEventEnvelope } from '../../../shared/events/fan-out'; + +const event = (payload: Record): DomainEventEnvelope => ({ + name: 'resource.disputed', + occurredOn: '2026-07-01T00:00:00.000Z', + aggregateId: 'resource-1', + payload, +}); + +describe('resourceDisputedHandler', () => { + it('delegates to AutoHideDisputedResource with the resource and emergency ids', async () => { + const execute = jest.fn().mockResolvedValue(undefined); + const autoHide = { execute } as unknown as AutoHideDisputedResource; + const handler = resourceDisputedHandler(autoHide); + + await handler(event({ emergencyId: 'emg-1' })); + + expect(execute).toHaveBeenCalledWith({ + resourceId: 'resource-1', + emergencyId: 'emg-1', + }); + }); + + it('ignores a malformed payload without calling the use case', async () => { + const execute = jest.fn().mockResolvedValue(undefined); + const autoHide = { execute } as unknown as AutoHideDisputedResource; + const handler = resourceDisputedHandler(autoHide); + + await handler(event({})); + + expect(execute).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/contexts/resources/infrastructure/resource-disputed.handler.ts b/apps/api/src/contexts/resources/infrastructure/resource-disputed.handler.ts new file mode 100644 index 00000000..029ecfbe --- /dev/null +++ b/apps/api/src/contexts/resources/infrastructure/resource-disputed.handler.ts @@ -0,0 +1,31 @@ +import { Logger } from '@nestjs/common'; +import { DomainEventEnvelope } from '../../../shared/events/fan-out'; +import { EventHandler } from '../../../shared/events/consumer-worker'; +import { AutoHideDisputedResource } from '../application/auto-hide-disputed-resource'; + +/** + * Handler for `resource.disputed` on the resources consumer queue: applies the + * opt-in auto-hide-on-dispute policy (#171). The use case itself no-ops when + * the emergency hasn't turned the policy on, so the default MVP behavior + * (visible with a badge, human confirms) is unchanged for every emergency + * unless a coordinator explicitly opts in. + */ +export function resourceDisputedHandler( + autoHide: AutoHideDisputedResource, +): EventHandler { + const logger = new Logger('resourceDisputedHandler'); + return async (event: DomainEventEnvelope): Promise => { + const payload = event.payload as { emergencyId?: unknown }; + if (typeof payload.emergencyId !== 'string') { + logger.warn( + `Skipping malformed ${event.name} for resource ${event.aggregateId}`, + ); + return; + } + + await autoHide.execute({ + resourceId: event.aggregateId, + emergencyId: payload.emergencyId, + }); + }; +} diff --git a/apps/api/src/contexts/resources/infrastructure/resources.module.ts b/apps/api/src/contexts/resources/infrastructure/resources.module.ts index 7e83d4b3..9cae87f1 100644 --- a/apps/api/src/contexts/resources/infrastructure/resources.module.ts +++ b/apps/api/src/contexts/resources/infrastructure/resources.module.ts @@ -30,6 +30,7 @@ import { ReceiveDonationIntoInventory } from '../application/receive-donation-in import { ConsumerWorker } from '../../../shared/events/consumer-worker'; import { DrizzleProcessedEventStore } from '../../../shared/events/drizzle-processed-event-store'; import { receiveDonationHandler } from './donation-received.handler'; +import { resourceDisputedHandler } from './resource-disputed.handler'; import { RESOURCE_REPOSITORY, ResourceRepository, @@ -42,10 +43,18 @@ 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 { DrizzleEmergencyAutoHideOnDisputeReader } from '../../../shared/drizzle-emergency-auto-hide-on-dispute-reader'; +import { DrizzleAuditTrail } from '../../../shared/drizzle-audit-trail'; import { EMERGENCY_DISPUTE_THRESHOLD_READER, EmergencyDisputeThresholdReader, } from '../domain/ports/emergency-dispute-threshold-reader'; +import { + EMERGENCY_AUTO_HIDE_ON_DISPUTE_READER, + EmergencyAutoHideOnDisputeReader, +} from '../domain/ports/emergency-auto-hide-on-dispute-reader'; +import { AUDIT_TRAIL, AuditTrail } from '../domain/ports/audit-trail'; +import { AutoHideDisputedResource } from '../application/auto-hide-disputed-resource'; import { DrizzleOrganizationAccreditationReader } from '../../../shared/drizzle-organization-accreditation-reader'; import { BullMqEventBus } from './bullmq-event-bus'; import { IdentityModule } from '../../identity/infrastructure/identity.module'; @@ -123,6 +132,20 @@ const emergencyDisputeThresholdReaderProvider = { new DrizzleEmergencyDisputeThresholdReader(db), }; +// Per-emergency opt-in auto-hide-on-dispute policy (#171). +const emergencyAutoHideOnDisputeReaderProvider = { + provide: EMERGENCY_AUTO_HIDE_ON_DISPUTE_READER, + inject: [DB], + useFactory: (db: Db): EmergencyAutoHideOnDisputeReader => + new DrizzleEmergencyAutoHideOnDisputeReader(db), +}; + +const auditTrailProvider = { + provide: AUDIT_TRAIL, + inject: [DB], + useFactory: (db: Db): AuditTrail => new DrizzleAuditTrail(db), +}; + const busProvider = { provide: EVENT_BUS, inject: [EVENT_QUEUE], @@ -320,6 +343,23 @@ const resolveResourceDisputeProvider = { ) => new ResolveResourceDispute(repo, validityRepo, bus), }; +// Auto-hide-on-dispute (#171): reuses ResolveResourceDispute's confirm_closed +// path so the automatic transition is identical to a human coordinator's, +// gated by the per-emergency opt-in policy and traced as a "system" action. +const autoHideDisputedResourceProvider = { + provide: AutoHideDisputedResource, + inject: [ + EMERGENCY_AUTO_HIDE_ON_DISPUTE_READER, + ResolveResourceDispute, + AUDIT_TRAIL, + ], + useFactory: ( + policy: EmergencyAutoHideOnDisputeReader, + resolve: ResolveResourceDispute, + audit: AuditTrail, + ) => new AutoHideDisputedResource(policy, resolve, audit), +}; + const getDisputedResourcesProvider = { provide: GetDisputedResources, inject: [RESOURCE_REPOSITORY, RESOURCE_VALIDITY_REPORT_REPOSITORY], @@ -368,16 +408,23 @@ const processedEventStoreProvider = { }; // Resources consumer of the domain-event fan-out: applies received donation -// lines to the target point's inventory, at most once per intake. +// lines to the target point's inventory, at most once per intake, and — #171 +// — auto-hides a disputed resource when its emergency opted into that policy. const donationEventsWorkerProvider = { provide: ConsumerWorker, - inject: [DrizzleProcessedEventStore, ReceiveDonationIntoInventory], + inject: [ + DrizzleProcessedEventStore, + ReceiveDonationIntoInventory, + AutoHideDisputedResource, + ], useFactory: ( store: DrizzleProcessedEventStore, receive: ReceiveDonationIntoInventory, + autoHide: AutoHideDisputedResource, ) => new ConsumerWorker('resources', store, { 'donation_intake.received': receiveDonationHandler(receive), + 'resource.disputed': resourceDisputedHandler(autoHide), }), }; @@ -407,6 +454,8 @@ const recordInventoryEntryProvider = { resourceRepositoryProvider, emergencyStatusReaderProvider, emergencyDisputeThresholdReaderProvider, + emergencyAutoHideOnDisputeReaderProvider, + auditTrailProvider, organizationAccreditationReaderProvider, membershipReaderProvider, busProvider, @@ -431,6 +480,7 @@ const recordInventoryEntryProvider = { validityReportRepositoryProvider, reportResourceValidityProvider, resolveResourceDisputeProvider, + autoHideDisputedResourceProvider, getDisputedResourcesProvider, getResourceValidityReportsProvider, listResourcesAdminProvider, diff --git a/apps/api/src/shared/drizzle-audit-trail.ts b/apps/api/src/shared/drizzle-audit-trail.ts new file mode 100644 index 00000000..f4634ed3 --- /dev/null +++ b/apps/api/src/shared/drizzle-audit-trail.ts @@ -0,0 +1,45 @@ +import { randomUUID } from 'node:crypto'; +import { Db } from './db'; +import { AuditEntry } from '../contexts/audit/domain/audit-entry'; +import { DrizzleAuditRepository } from '../contexts/audit/infrastructure/drizzle/drizzle-audit.repository'; +import { + AuditTrail, + SystemAuditEntry, +} from '../contexts/resources/domain/ports/audit-trail'; + +/** + * Shared Drizzle adapter — persists a system-attributed audit entry directly + * (there is no HTTP request for `AuditInterceptor` to piggyback on, unlike a + * coordinator's confirm_closed action). Used by the resources context's + * automatic dispute-resolution handler (#171) so the auto-hide action leaves + * the same activity-trail entry a human coordinator's action would, attributed + * to "system" instead of a user. Accepted cross-context infra coupling, + * following the same pattern as DrizzleEmergencyDisputeThresholdReader. + */ +export class DrizzleAuditTrail implements AuditTrail { + private readonly repo: DrizzleAuditRepository; + + constructor(db: Db) { + this.repo = new DrizzleAuditRepository(db); + } + + async recordSystemAction(entry: SystemAuditEntry): Promise { + await this.repo.save( + AuditEntry.create({ + id: randomUUID(), + actorUserId: null, + actorName: 'system', + action: entry.action, + entityType: entry.entityType, + entityId: entry.entityId, + emergencyId: entry.emergencyId, + method: 'SYSTEM', + path: `/internal/${entry.action}`, + statusCode: 200, + reason: entry.reason, + changes: entry.changes, + targetStatus: entry.targetStatus, + }), + ); + } +} diff --git a/apps/api/src/shared/drizzle-emergency-auto-hide-on-dispute-reader.ts b/apps/api/src/shared/drizzle-emergency-auto-hide-on-dispute-reader.ts new file mode 100644 index 00000000..5aec0f8e --- /dev/null +++ b/apps/api/src/shared/drizzle-emergency-auto-hide-on-dispute-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 auto_hide_on_dispute from emergencies. + * + * Used by the resources context's `ResourceDisputed` handler (#171) to decide + * whether to auto-resolve a dispute on threshold. Accepted cross-context infra + * coupling, following the same pattern as DrizzleEmergencyDisputeThresholdReader. + */ +export class DrizzleEmergencyAutoHideOnDisputeReader { + constructor(private readonly db: Db) {} + + async getAutoHideOnDispute(emergencyId: string): Promise { + const rows = await this.db + .select({ + autoHideOnDispute: emergenciesTable.autoHideOnDispute, + }) + .from(emergenciesTable) + .where(eq(emergenciesTable.id, emergencyId)); + return rows[0]?.autoHideOnDispute ?? false; + } +} diff --git a/apps/api/src/shared/events/subscriptions.ts b/apps/api/src/shared/events/subscriptions.ts index 4ab22b70..abff186a 100644 --- a/apps/api/src/shared/events/subscriptions.ts +++ b/apps/api/src/shared/events/subscriptions.ts @@ -18,7 +18,10 @@ export interface EventSubscription { } export const EVENT_SUBSCRIPTIONS: readonly EventSubscription[] = [ - { consumer: 'resources', events: ['donation_intake.received'] }, + { + consumer: 'resources', + events: ['donation_intake.received', 'resource.disputed'], + }, { consumer: 'notifications', events: ['donation_intake.received'] }, ]; diff --git a/apps/web/src/app/emergencies/[slug]/manage/actions.ts b/apps/web/src/app/emergencies/[slug]/manage/actions.ts index b7001cf9..8461fb3a 100644 --- a/apps/web/src/app/emergencies/[slug]/manage/actions.ts +++ b/apps/web/src/app/emergencies/[slug]/manage/actions.ts @@ -702,3 +702,41 @@ export async function publishAnnouncement( revalidatePath(`/e/${slug}`); return { status: 'success' }; } + +/** + * Turns the opt-in auto-hide-on-dispute policy (#171) on or off for an + * emergency. When on, a disputed point that reaches the threshold is closed + * automatically (same transition a coordinator's "Confirmar cierre" performs) + * instead of just staying visible with a badge. + */ +export async function setAutoHideOnDispute( + emergencyId: string, + slug: string, + enabled: boolean, +): Promise { + const token = await requireSession(`/emergencies/${slug}/manage`); + + const { t } = await getT(); + + const { error, response } = await api.PUT( + '/emergencies/{emergencyId}/auto-hide-on-dispute', + { + params: { path: { emergencyId } }, + body: { enabled }, + headers: authHeaders(token), + }, + ); + + if (error !== undefined) { + if (response.status === 401) { + return redirectToLogin(`/emergencies/${slug}/manage`); + } + if (response.status === 403) { + return { status: 'error', message: t.coord.err_no_permission_configure }; + } + return { status: 'error', message: t.coord.err_auto_hide_failed }; + } + + revalidatePath(`/emergencies/${slug}/manage`); + return { status: 'success' }; +} diff --git a/apps/web/src/app/emergencies/[slug]/manage/page.tsx b/apps/web/src/app/emergencies/[slug]/manage/page.tsx index 33683577..0fa3327f 100644 --- a/apps/web/src/app/emergencies/[slug]/manage/page.tsx +++ b/apps/web/src/app/emergencies/[slug]/manage/page.tsx @@ -48,6 +48,7 @@ export default async function ManageOverviewPage({ params }: Props) { offersPending, shipmentsActive, disputesPending, + autoHideOnDispute, ] = await Promise.all([ access.canVerifyResources ? api @@ -106,6 +107,20 @@ export default async function ManageOverviewPage({ params }: Props) { return r.data?.length ?? 0; }) : Promise.resolve(null), + // #171: the auto-hide-on-dispute policy is only exposed on the + // authenticated "mine" view — find this emergency's entry there so the + // coordinator panel can show/save its current value. + access.canCoordinate + ? api + .GET('/emergencies/mine', { headers }) + .then(async (r) => { + await onUnauthorized(r.response.status); + return ( + r.data?.find((e) => e.id === emergencyId)?.autoHideOnDispute ?? + null + ); + }) + : Promise.resolve(null), ]); const base = `/emergencies/${slug}/manage`; @@ -206,6 +221,7 @@ export default async function ManageOverviewPage({ params }: Props) { ? emergency.announcement : null } + autoHideOnDispute={autoHideOnDispute ?? undefined} /> )} diff --git a/apps/web/src/components/organisms/emergency-controls.tsx b/apps/web/src/components/organisms/emergency-controls.tsx index cb6da39c..25af12a3 100644 --- a/apps/web/src/components/organisms/emergency-controls.tsx +++ b/apps/web/src/components/organisms/emergency-controls.tsx @@ -5,6 +5,7 @@ import { pauseEmergency, resumeEmergency, publishAnnouncement, + setAutoHideOnDispute, } from '@/app/emergencies/[slug]/manage/actions'; import type { ActionResult } from '@/app/emergencies/[slug]/manage/actions'; import { Button } from '@/components/atoms/button'; @@ -18,6 +19,8 @@ interface EmergencyControlsProps { slug: string; status: 'active' | 'paused' | 'closed'; currentAnnouncement: string | null; + /** Opt-in auto-hide-on-dispute policy (#171); undefined when unknown to the caller. */ + autoHideOnDispute?: boolean; } const IDLE: ActionResult = { status: 'idle' }; @@ -27,6 +30,7 @@ export function EmergencyControls({ slug, status, currentAnnouncement, + autoHideOnDispute, }: EmergencyControlsProps) { const tc = getMessages(useLocale()).coord; @@ -61,6 +65,18 @@ export function EmergencyControls({ IDLE, ); + // --- Auto-hide-on-dispute policy state (#171) ----------------------------- + const [autoHideState, autoHideAction, autoHidePending] = useActionState< + ActionResult, + FormData + >( + async (_prev, formData) => { + const enabled = formData.get('enabled') === 'true'; + return setAutoHideOnDispute(emergencyId, slug, enabled); + }, + IDLE, + ); + const isClosed = status === 'closed'; return ( @@ -160,6 +176,62 @@ export function EmergencyControls({ + + {/* ── Auto-hide-on-dispute policy (#171) ──────────────────────────── */} + {autoHideOnDispute !== undefined && ( +
+

+ {tc.controls_auto_hide_heading} +

+ + {autoHideState.status === 'error' && ( + + )} + {autoHideState.status === 'success' && ( +

+ {tc.controls_auto_hide_saved} +

+ )} + +
+
+ + +
+ +
+
+ )} ); } diff --git a/apps/web/src/i18n/messages/en.ts b/apps/web/src/i18n/messages/en.ts index fc732a11..659dee5e 100644 --- a/apps/web/src/i18n/messages/en.ts +++ b/apps/web/src/i18n/messages/en.ts @@ -2443,6 +2443,15 @@ export const en = { controls_announcement_placeholder: 'Write the official announcement for citizens here…', controls_announcement_publish: 'Publish announcement', controls_announcement_publishing: 'Publishing…', + controls_auto_hide_heading: 'Point disputes', + controls_auto_hide_label: 'Auto-hide on reaching the dispute threshold', + controls_auto_hide_description: + 'When on, a disputed point closes automatically once it reaches the threshold (same action as "Confirm closed"). When off (default), the point stays visible with a warning until a coordinator confirms or dismisses it.', + controls_auto_hide_save: 'Save', + controls_auto_hide_saving: 'Saving…', + controls_auto_hide_saved: 'Policy updated.', + err_auto_hide_failed: 'Could not update the auto-hide policy.', + err_no_permission_configure: 'You do not have permission to configure this emergency.', task_form_label: 'Create new task', task_form_heading: 'New task', diff --git a/apps/web/src/i18n/messages/es.ts b/apps/web/src/i18n/messages/es.ts index 60c8a3c3..4f06edc9 100644 --- a/apps/web/src/i18n/messages/es.ts +++ b/apps/web/src/i18n/messages/es.ts @@ -2488,6 +2488,15 @@ export const es = { controls_announcement_placeholder: 'Escribe aquí el comunicado oficial para los ciudadanos…', controls_announcement_publish: 'Publicar comunicado', controls_announcement_publishing: 'Publicando…', + controls_auto_hide_heading: 'Disputas de puntos', + controls_auto_hide_label: 'Auto-ocultar al alcanzar el umbral de disputa', + controls_auto_hide_description: + 'Si está activa, un punto disputado se cierra automáticamente al llegar al umbral (misma acción que "Confirmar cierre"). Si está desactivada (por defecto), el punto sigue visible con un aviso hasta que un coordinador lo confirme o lo descarte.', + controls_auto_hide_save: 'Guardar', + controls_auto_hide_saving: 'Guardando…', + controls_auto_hide_saved: 'Política actualizada.', + err_auto_hide_failed: 'No se pudo actualizar la política de auto-ocultado.', + err_no_permission_configure: 'No tienes permiso para configurar esta emergencia.', task_form_label: 'Crear nueva tarea', task_form_heading: 'Nueva tarea', diff --git a/packages/api-client/src/schema.ts b/packages/api-client/src/schema.ts index 3743dec4..5d20f026 100644 --- a/packages/api-client/src/schema.ts +++ b/packages/api-client/src/schema.ts @@ -936,6 +936,23 @@ export interface paths { patch?: never; trace?: never; }; + "/emergencies/{emergencyId}/auto-hide-on-dispute": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Activar o desactivar la política de auto-ocultado por disputa (#171) */ + put: operations["EmergenciesController_setEmergencyAutoHideOnDispute"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/emergencies/{emergencyId}/needs": { parameters: { query?: never; @@ -4113,6 +4130,11 @@ export interface components { * @example 5 */ resourceDisputeThreshold: number | null; + /** + * @description Política opt-in (#171): si está activa, un punto disputado que alcanza el umbral se cierra automáticamente (misma transición que "confirmar cierre"); si no, el comportamiento actual (visible con badge, confirma un coordinador). Off por defecto. Solo se expone en la vista autenticada. + * @example false + */ + autoHideOnDispute: boolean; }; CreateEmergencyFromTemplateDto: { /** @example aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa */ @@ -4138,6 +4160,13 @@ export interface components { */ threshold: number | null; }; + SetAutoHideOnDisputeDto: { + /** + * @description Activa (true) o desactiva (false) la política de auto-ocultado (#171). + * @example true + */ + enabled: boolean; + }; NeedLocationDto: { /** @example 123 Main Street, Caracas, Venezuela */ address: string; @@ -8613,6 +8642,59 @@ export interface operations { }; }; }; + EmergenciesController_setEmergencyAutoHideOnDispute: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Emergency UUID */ + emergencyId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SetAutoHideOnDisputeDto"]; + }; + }; + responses: { + /** @description Política actualizada */ + 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;