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
5 changes: 5 additions & 0 deletions apps/api/drizzle/0042_emergency_dispute_threshold.sql
Original file line number Diff line number Diff line change
@@ -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
67 changes: 67 additions & 0 deletions apps/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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).",
Expand Down Expand Up @@ -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": {
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
}
}
14 changes: 14 additions & 0 deletions apps/api/src/contexts/emergencies/domain/emergency.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export interface EmergencySnapshot {
status: EmergencyStatus;
announcement: string | null;
dontBringList: string[];
resourceDisputeThreshold: number | null;
createdAt: Date;
updatedAt: Date;
}
Expand All @@ -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,
) {}
Expand All @@ -47,6 +49,7 @@ export class Emergency {
EmergencyStatus.Active,
props.announcement ?? null,
props.dontBringList ?? [],
null,
now,
now,
);
Expand All @@ -61,6 +64,7 @@ export class Emergency {
snap.status,
snap.announcement,
snap.dontBringList,
snap.resourceDisputeThreshold,
snap.createdAt,
snap.updatedAt,
);
Expand All @@ -78,6 +82,10 @@ export class Emergency {
return this._dontBringList;
}

get resourceDisputeThreshold(): number | null {
return this._resourceDisputeThreshold;
}

get updatedAt(): Date {
return this._updatedAt;
}
Expand Down Expand Up @@ -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,
Expand All @@ -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,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -49,6 +50,7 @@ export class DrizzleEmergencyRepository implements EmergencyRepository {
country: s.country,
announcement: s.announcement,
dontBringList: s.dontBringList,
resourceDisputeThreshold: s.resourceDisputeThreshold,
updatedAt: s.updatedAt,
},
});
Expand Down
Original file line number Diff line number Diff line change
@@ -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(),
Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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],
Expand All @@ -93,6 +101,7 @@ const createFromTemplateProvider = {
resumeEmergencyProvider,
publishAnnouncementProvider,
createFromTemplateProvider,
setDisputeThresholdProvider,
],
})
export class EmergenciesModule {}
17 changes: 17 additions & 0 deletions apps/api/src/contexts/emergencies/infrastructure/http/dto.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {
IsInt,
IsNotEmpty,
IsOptional,
IsPositive,
IsString,
IsUUID,
Matches,
Expand Down Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,15 @@ 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,
CreateEmergencyResponseDto,
EmergencyViewDto,
MyEmergencyViewDto,
PublishAnnouncementDto,
SetDisputeThresholdDto,
} from './dto';
import { EmergencyExceptionFilter } from './emergency-exception.filter';
import {
Expand All @@ -64,6 +66,7 @@ export class EmergenciesController {
private readonly resume: ResumeEmergency,
private readonly publishAnnouncement: PublishAnnouncement,
private readonly createFromTemplate: CreateEmergencyFromTemplate,
private readonly setDisputeThreshold: SetEmergencyDisputeThreshold,
) {}

@Post()
Expand Down Expand Up @@ -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<void> {
await this.setDisputeThreshold.execute({
emergencyId,
threshold: dto.threshold ?? null,
});
}
}
Loading