Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,4 @@ apps/api/uploads/

# Local git worktrees for parallel agent work — never commit
.worktrees/
.claude/worktrees/
5 changes: 5 additions & 0 deletions apps/api/drizzle/0060_emergency_auto_hide_on_dispute.sql
Original file line number Diff line number Diff line change
@@ -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;
74 changes: 73 additions & 1 deletion apps/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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).",
Expand Down Expand Up @@ -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": [
Expand All @@ -12038,7 +12096,8 @@
"dontBringList",
"updatedAt",
"roleIds",
"resourceDisputeThreshold"
"resourceDisputeThreshold",
"autoHideOnDispute"
]
},
"CreateEmergencyFromTemplateDto": {
Expand Down Expand Up @@ -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": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ describe('ListMyEmergencies', () => {
announcement: null,
dontBringList: [],
resourceDisputeThreshold: 7,
autoHideOnDispute: true,
createdAt: new Date(),
updatedAt: new Date(),
});
Expand All @@ -42,6 +43,7 @@ describe('ListMyEmergencies', () => {
announcement: null,
dontBringList: [],
resourceDisputeThreshold: null,
autoHideOnDispute: false,
createdAt: new Date(),
updatedAt: new Date(),
});
Expand All @@ -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 () => {
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -66,6 +68,7 @@ export class ListMyEmergencies {
...toEmergencyView(e),
roleIds: roleIdsByEmergency.get(e.id.value) ?? [],
resourceDisputeThreshold: e.resourceDisputeThreshold,
autoHideOnDispute: e.autoHideOnDispute,
}));
}
}
Original file line number Diff line number Diff line change
@@ -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();
});
});
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
};
Expand Down
24 changes: 24 additions & 0 deletions apps/api/src/contexts/emergencies/domain/emergency.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
});
});
});
Loading
Loading