diff --git a/apps/api/drizzle/0027_recommended_lists.sql b/apps/api/drizzle/0027_recommended_lists.sql new file mode 100644 index 00000000..2bd5b32c --- /dev/null +++ b/apps/api/drizzle/0027_recommended_lists.sql @@ -0,0 +1,3 @@ +-- Listas de recomendación y "qué sí llevar" para templates y emergencias. +ALTER TABLE templates ADD COLUMN recommended_list text[] NOT NULL DEFAULT '{}'; +ALTER TABLE emergencies ADD COLUMN recommended_list text[] NOT NULL DEFAULT '{}'; diff --git a/apps/api/openapi.json b/apps/api/openapi.json index e4a0c69a..99f422ba 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -5861,12 +5861,25 @@ "type": "string", "example": "No se aceptan mascotas en el centro de acopio.", "nullable": true + }, + "recommendedList": { + "example": [ + "agua", + "dieta líquida", + "ítems EV" + ], + "description": "Items and priorities people SHOULD bring", + "type": "array", + "items": { + "type": "string" + } } }, "required": [ "name", "description", - "dontBringList" + "dontBringList", + "recommendedList" ] }, "CreateTemplateResponseDto": { @@ -5914,6 +5927,16 @@ "createdAt": { "type": "string", "example": "2026-06-27T10:00:00.000Z" + }, + "recommendedList": { + "example": [ + "agua", + "dieta líquida" + ], + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -5922,7 +5945,8 @@ "description", "dontBringList", "defaultAnnouncement", - "createdAt" + "createdAt", + "recommendedList" ] }, "CreateEmergencyDto": { @@ -6016,6 +6040,18 @@ "updatedAt": { "type": "string", "example": "2026-06-25T10:00:00.000Z" + }, + "recommendedList": { + "example": [ + "agua", + "dieta líquida", + "ítems EV" + ], + "description": "Items and priorities people SHOULD bring to the emergency", + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -6026,7 +6062,8 @@ "status", "announcement", "dontBringList", - "updatedAt" + "updatedAt", + "recommendedList" ] }, "CreateEmergencyFromTemplateDto": { @@ -8061,4 +8098,4 @@ } } } -} \ No newline at end of file +} diff --git a/apps/api/scripts/seed-templates.ts b/apps/api/scripts/seed-templates.ts index 4d984101..6939beb1 100644 --- a/apps/api/scripts/seed-templates.ts +++ b/apps/api/scripts/seed-templates.ts @@ -24,6 +24,14 @@ const DONT_BRING_LIST = [ 'Personas sin formación sanitaria mínima en zonas de atención', ]; +const RECOMMENDED_LIST = [ + 'Agua potable y alimento de fácil consumo', + 'Documento de identidad y tarjeta sanitaria', + 'Medicamentos personales con receta o prospecto', + 'Ropa de recambio y cargadores portátiles', + 'Mascarillas, guantes y gel hidroalcohólico', +]; + const DEFAULT_ANNOUNCEMENT = 'Activado protocolo de emergencia sanitaria. Coordinamos necesidades de ' + 'medicamentos, equipos e insumos médicos. Solo personal sanitario ' + @@ -46,6 +54,7 @@ async function seed(): Promise { 'Plantilla para emergencias del vertical sanitario: hospitales, ' + 'refugios médicos y situaciones que requieren taxonomía médica.', dontBringList: DONT_BRING_LIST, + recommendedList: RECOMMENDED_LIST, defaultAnnouncement: DEFAULT_ANNOUNCEMENT, createdAt: new Date(), }) @@ -54,9 +63,10 @@ async function seed(): Promise { set: { name: 'Emergencia sanitaria', description: - 'Plantilla para emergencias del vertical sanitario: hospitales, ' + - 'refugios médicos y situaciones que requieren taxonomía médica.', + 'Plantilla para emergencias del vertical sanitario: hospitales, ' + + 'refugios médicos y situaciones que requieren taxonomía médica.', dontBringList: DONT_BRING_LIST, + recommendedList: RECOMMENDED_LIST, defaultAnnouncement: DEFAULT_ANNOUNCEMENT, }, }); diff --git a/apps/api/src/contexts/emergencies/application/create-emergency-from-template.spec.ts b/apps/api/src/contexts/emergencies/application/create-emergency-from-template.spec.ts index 8c9ac191..e88c2a47 100644 --- a/apps/api/src/contexts/emergencies/application/create-emergency-from-template.spec.ts +++ b/apps/api/src/contexts/emergencies/application/create-emergency-from-template.spec.ts @@ -12,6 +12,7 @@ const TEMPLATE_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; describe('CreateEmergencyFromTemplate', () => { function makeTemplate(opts: { dontBringList?: string[]; + recommendedList?: string[]; defaultAnnouncement?: string | null; }) { return Template.create({ @@ -19,16 +20,18 @@ describe('CreateEmergencyFromTemplate', () => { name: 'Terremoto básico', description: 'Template de prueba', dontBringList: opts.dontBringList ?? [], + recommendedList: opts.recommendedList ?? [], defaultAnnouncement: opts.defaultAnnouncement ?? null, }); } - it('creates an emergency copying dontBringList and announcement from template', async () => { + it('creates an emergency copying lists and announcement from template', async () => { const emergencyRepo = new InMemoryEmergencyRepository(); const templateRepo = new InMemoryTemplateRepository(); await templateRepo.save( makeTemplate({ dontBringList: ['mascotas', 'joyas'], + recommendedList: ['agua', 'dieta líquida'], defaultAnnouncement: 'No traer mascotas', }), ); @@ -52,6 +55,7 @@ describe('CreateEmergencyFromTemplate', () => { ); expect(created).not.toBeNull(); expect(created?.dontBringList).toEqual(['mascotas', 'joyas']); + expect(created?.recommendedList).toEqual(['agua', 'dieta líquida']); expect(created?.announcement).toBe('No traer mascotas'); }); @@ -59,7 +63,11 @@ describe('CreateEmergencyFromTemplate', () => { const emergencyRepo = new InMemoryEmergencyRepository(); const templateRepo = new InMemoryTemplateRepository(); await templateRepo.save( - makeTemplate({ dontBringList: [], defaultAnnouncement: null }), + makeTemplate({ + dontBringList: [], + recommendedList: [], + defaultAnnouncement: null, + }), ); const useCase = new CreateEmergencyFromTemplate( diff --git a/apps/api/src/contexts/emergencies/application/create-emergency-from-template.ts b/apps/api/src/contexts/emergencies/application/create-emergency-from-template.ts index fae821d8..e9dbc830 100644 --- a/apps/api/src/contexts/emergencies/application/create-emergency-from-template.ts +++ b/apps/api/src/contexts/emergencies/application/create-emergency-from-template.ts @@ -37,6 +37,7 @@ export class CreateEmergencyFromTemplate { slug, country: cmd.country, dontBringList: template.dontBringList, + recommendedList: template.recommendedList, announcement: template.defaultAnnouncement, }); diff --git a/apps/api/src/contexts/emergencies/application/emergency-view.ts b/apps/api/src/contexts/emergencies/application/emergency-view.ts index 7fb5551a..0a047918 100644 --- a/apps/api/src/contexts/emergencies/application/emergency-view.ts +++ b/apps/api/src/contexts/emergencies/application/emergency-view.ts @@ -8,6 +8,7 @@ export interface EmergencyView { status: string; announcement: string | null; dontBringList: string[]; + recommendedList: string[]; updatedAt: string; } @@ -20,6 +21,7 @@ export function toEmergencyView(e: Emergency): EmergencyView { status: e.status, announcement: e.announcement, dontBringList: e.dontBringList, + recommendedList: e.recommendedList, updatedAt: e.updatedAt.toISOString(), }; } diff --git a/apps/api/src/contexts/emergencies/application/list-active-emergencies.spec.ts b/apps/api/src/contexts/emergencies/application/list-active-emergencies.spec.ts index 72b1ee37..01af12a5 100644 --- a/apps/api/src/contexts/emergencies/application/list-active-emergencies.spec.ts +++ b/apps/api/src/contexts/emergencies/application/list-active-emergencies.spec.ts @@ -23,7 +23,11 @@ describe('ListActiveEmergencies', () => { slug: 'closed-relief', country: 'ES', status: EmergencyStatus.Closed, + announcement: null, + dontBringList: [], + recommendedList: [], createdAt: new Date(), + updatedAt: new Date(), }); await repo.save(closed); diff --git a/apps/api/src/contexts/emergencies/domain/emergency.spec.ts b/apps/api/src/contexts/emergencies/domain/emergency.spec.ts index a884a62f..df79a35d 100644 --- a/apps/api/src/contexts/emergencies/domain/emergency.spec.ts +++ b/apps/api/src/contexts/emergencies/domain/emergency.spec.ts @@ -26,6 +26,7 @@ describe('Emergency', () => { it('creates with null announcement and updatedAt equal to createdAt', () => { const e = makeEmergency(); expect(e.announcement).toBeNull(); + expect(e.recommendedList).toEqual([]); expect(e.updatedAt.toISOString()).toBe(e.createdAt.toISOString()); }); @@ -132,6 +133,7 @@ describe('Emergency', () => { expect(restored.slug.equals(e.slug)).toBe(true); expect(restored.status).toBe(EmergencyStatus.Paused); expect(restored.announcement).toBe('Round-trip test'); + expect(restored.recommendedList).toEqual([]); expect(restored.updatedAt.toISOString()).toBe(e.updatedAt.toISOString()); expect(restored.country).toBe('TR'); expect(restored.createdAt.toISOString()).toBe(e.createdAt.toISOString()); diff --git a/apps/api/src/contexts/emergencies/domain/emergency.ts b/apps/api/src/contexts/emergencies/domain/emergency.ts index 2b73cafd..22bcc052 100644 --- a/apps/api/src/contexts/emergencies/domain/emergency.ts +++ b/apps/api/src/contexts/emergencies/domain/emergency.ts @@ -9,6 +9,7 @@ export interface CreateEmergencyProps { slug: Slug; country: string; dontBringList?: string[]; + recommendedList?: string[]; announcement?: string | null; } @@ -20,6 +21,7 @@ export interface EmergencySnapshot { status: EmergencyStatus; announcement: string | null; dontBringList: string[]; + recommendedList: string[]; createdAt: Date; updatedAt: Date; } @@ -33,6 +35,7 @@ export class Emergency { private _status: EmergencyStatus, private _announcement: string | null, private _dontBringList: string[], + private _recommendedList: string[], public readonly createdAt: Date, private _updatedAt: Date, ) {} @@ -47,6 +50,7 @@ export class Emergency { EmergencyStatus.Active, props.announcement ?? null, props.dontBringList ?? [], + props.recommendedList ?? [], now, now, ); @@ -61,6 +65,7 @@ export class Emergency { snap.status, snap.announcement, snap.dontBringList, + snap.recommendedList, snap.createdAt, snap.updatedAt, ); @@ -78,6 +83,10 @@ export class Emergency { return this._dontBringList; } + get recommendedList(): string[] { + return this._recommendedList; + } + get updatedAt(): Date { return this._updatedAt; } @@ -117,6 +126,7 @@ export class Emergency { status: this._status, announcement: this._announcement, dontBringList: this._dontBringList, + recommendedList: this._recommendedList, createdAt: this.createdAt, updatedAt: this._updatedAt, }; diff --git a/apps/api/src/contexts/emergencies/infrastructure/drizzle/drizzle-emergency.repository.int-spec.ts b/apps/api/src/contexts/emergencies/infrastructure/drizzle/drizzle-emergency.repository.int-spec.ts index 0a5bf39b..2e0dc860 100644 --- a/apps/api/src/contexts/emergencies/infrastructure/drizzle/drizzle-emergency.repository.int-spec.ts +++ b/apps/api/src/contexts/emergencies/infrastructure/drizzle/drizzle-emergency.repository.int-spec.ts @@ -36,6 +36,7 @@ describe('DrizzleEmergencyRepository (integration)', () => { name: 'Emergencia sísmica — Venezuela', slug: Slug.fromString('venezuela'), country: 'VE', + recommendedList: ['agua', 'dieta líquida'], }); await repo.save(emergency); @@ -47,6 +48,7 @@ describe('DrizzleEmergencyRepository (integration)', () => { expect(found?.slug.value).toBe('venezuela'); expect(found?.country).toBe('VE'); expect(found?.status).toBe(EmergencyStatus.Active); + expect(found?.recommendedList).toEqual(['agua', 'dieta líquida']); }); it('findBySlug returns the correct emergency', async () => { @@ -55,6 +57,7 @@ describe('DrizzleEmergencyRepository (integration)', () => { name: 'Emergencia sísmica — Venezuela', slug: Slug.fromString('venezuela'), country: 'VE', + recommendedList: ['agua'], }); await repo.save(emergency); @@ -75,6 +78,7 @@ describe('DrizzleEmergencyRepository (integration)', () => { name: 'Active Emergency', slug: Slug.fromString('active-emergency'), country: 'VE', + recommendedList: ['agua'], }); const closed = Emergency.create({ @@ -82,6 +86,7 @@ describe('DrizzleEmergencyRepository (integration)', () => { name: 'Closed Emergency', slug: Slug.fromString('closed-emergency'), country: 'CO', + recommendedList: ['agua'], }); closed.close(); @@ -101,6 +106,7 @@ describe('DrizzleEmergencyRepository (integration)', () => { slug: Slug.fromString('dont-bring-test'), country: 'VE', dontBringList: ['mascotas', 'joyas'], + recommendedList: ['agua', 'dieta líquida'], }); await repo.save(emergency); @@ -108,5 +114,6 @@ describe('DrizzleEmergencyRepository (integration)', () => { expect(found).not.toBeNull(); expect(found?.dontBringList).toEqual(['mascotas', 'joyas']); + expect(found?.recommendedList).toEqual(['agua', 'dieta líquida']); }); }); 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 d4689e46..185d388e 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, + recommendedList: row.recommendedList, createdAt: row.createdAt, updatedAt: row.updatedAt, }; @@ -38,6 +39,7 @@ export class DrizzleEmergencyRepository implements EmergencyRepository { status: s.status, announcement: s.announcement, dontBringList: s.dontBringList, + recommendedList: s.recommendedList, createdAt: s.createdAt, updatedAt: s.updatedAt, }) @@ -49,6 +51,7 @@ export class DrizzleEmergencyRepository implements EmergencyRepository { country: s.country, announcement: s.announcement, dontBringList: s.dontBringList, + recommendedList: s.recommendedList, 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..5341b320 100644 --- a/apps/api/src/contexts/emergencies/infrastructure/drizzle/schema.ts +++ b/apps/api/src/contexts/emergencies/infrastructure/drizzle/schema.ts @@ -8,6 +8,7 @@ export const emergenciesTable = pgTable('emergencies', { status: text('status').notNull(), announcement: text('announcement'), dontBringList: text('dont_bring_list').array().notNull().default([]), + recommendedList: text('recommended_list').array().notNull().default([]), createdAt: timestamp('created_at', { withTimezone: true }).notNull(), updatedAt: timestamp('updated_at', { withTimezone: true }) .notNull() diff --git a/apps/api/src/contexts/emergencies/infrastructure/http/dto.ts b/apps/api/src/contexts/emergencies/infrastructure/http/dto.ts index 617e78cd..ed1a7811 100644 --- a/apps/api/src/contexts/emergencies/infrastructure/http/dto.ts +++ b/apps/api/src/contexts/emergencies/infrastructure/http/dto.ts @@ -77,6 +77,13 @@ export class EmergencyViewDto { }) dontBringList!: string[]; + @ApiProperty({ + example: ['agua', 'dieta líquida', 'ítems EV'], + type: [String], + description: 'Items and priorities people SHOULD bring to the emergency', + }) + recommendedList!: string[]; + @ApiProperty({ example: '2026-06-25T10:00:00.000Z' }) updatedAt!: string; } diff --git a/apps/api/src/contexts/templates/application/create-template.spec.ts b/apps/api/src/contexts/templates/application/create-template.spec.ts index 6eb39707..735820ef 100644 --- a/apps/api/src/contexts/templates/application/create-template.spec.ts +++ b/apps/api/src/contexts/templates/application/create-template.spec.ts @@ -10,6 +10,7 @@ describe('CreateTemplate', () => { name: 'Terremoto', description: 'Template básico para terremotos', dontBringList: ['mascotas', 'joyas'], + recommendedList: ['agua', 'dieta líquida'], defaultAnnouncement: 'No traer mascotas', }); @@ -20,6 +21,7 @@ describe('CreateTemplate', () => { expect(all).toHaveLength(1); expect(all[0].name).toBe('Terremoto'); expect(all[0].dontBringList).toEqual(['mascotas', 'joyas']); + expect(all[0].recommendedList).toEqual(['agua', 'dieta líquida']); expect(all[0].defaultAnnouncement).toBe('No traer mascotas'); }); @@ -31,6 +33,7 @@ describe('CreateTemplate', () => { name: 'Inundación', description: 'Template para inundaciones', dontBringList: [], + recommendedList: [], }); expect(typeof result.id).toBe('string'); diff --git a/apps/api/src/contexts/templates/application/create-template.ts b/apps/api/src/contexts/templates/application/create-template.ts index 3ce256cc..63304bda 100644 --- a/apps/api/src/contexts/templates/application/create-template.ts +++ b/apps/api/src/contexts/templates/application/create-template.ts @@ -6,6 +6,7 @@ export interface CreateTemplateCommand { name: string; description: string; dontBringList: string[]; + recommendedList: string[]; defaultAnnouncement?: string; } @@ -18,6 +19,7 @@ export class CreateTemplate { name: cmd.name, description: cmd.description, dontBringList: cmd.dontBringList, + recommendedList: cmd.recommendedList, defaultAnnouncement: cmd.defaultAnnouncement ?? null, }); await this.repo.save(template); diff --git a/apps/api/src/contexts/templates/application/delete-template.spec.ts b/apps/api/src/contexts/templates/application/delete-template.spec.ts index 48b0de42..91fa91a5 100644 --- a/apps/api/src/contexts/templates/application/delete-template.spec.ts +++ b/apps/api/src/contexts/templates/application/delete-template.spec.ts @@ -13,6 +13,7 @@ describe('DeleteTemplate', () => { name: 'To Delete', description: 'Desc', dontBringList: [], + recommendedList: [], }); await deleteUc.execute({ id }); diff --git a/apps/api/src/contexts/templates/application/template-view.ts b/apps/api/src/contexts/templates/application/template-view.ts index 8e670850..9c4848e4 100644 --- a/apps/api/src/contexts/templates/application/template-view.ts +++ b/apps/api/src/contexts/templates/application/template-view.ts @@ -5,6 +5,7 @@ export interface TemplateView { name: string; description: string; dontBringList: string[]; + recommendedList: string[]; defaultAnnouncement: string | null; createdAt: string; } @@ -15,6 +16,7 @@ export function toTemplateView(t: Template): TemplateView { name: t.name, description: t.description, dontBringList: t.dontBringList, + recommendedList: t.recommendedList, defaultAnnouncement: t.defaultAnnouncement, createdAt: t.createdAt.toISOString(), }; diff --git a/apps/api/src/contexts/templates/domain/template.spec.ts b/apps/api/src/contexts/templates/domain/template.spec.ts index 4c90e5e5..b23c0d22 100644 --- a/apps/api/src/contexts/templates/domain/template.spec.ts +++ b/apps/api/src/contexts/templates/domain/template.spec.ts @@ -10,6 +10,7 @@ describe('Template', () => { name: 'Terremoto básico', description: 'Template para terremotos de magnitud moderada', dontBringList: ['mascotas', 'vehículos grandes'], + recommendedList: ['agua', 'dieta líquida'], defaultAnnouncement: 'No traer mascotas al centro de acopio', }); @@ -17,6 +18,7 @@ describe('Template', () => { expect(t.name).toBe('Terremoto básico'); expect(t.description).toBe('Template para terremotos de magnitud moderada'); expect(t.dontBringList).toEqual(['mascotas', 'vehículos grandes']); + expect(t.recommendedList).toEqual(['agua', 'dieta líquida']); expect(t.defaultAnnouncement).toBe('No traer mascotas al centro de acopio'); expect(t.createdAt).toBeInstanceOf(Date); }); @@ -27,6 +29,7 @@ describe('Template', () => { name: 'Template sin anuncio', description: 'Descripción', dontBringList: [], + recommendedList: [], defaultAnnouncement: null, }); @@ -40,6 +43,7 @@ describe('Template', () => { name: 'Template round-trip', description: 'Desc', dontBringList: ['item1', 'item2'], + recommendedList: ['itemA'], defaultAnnouncement: 'Anuncio', }); @@ -49,6 +53,7 @@ describe('Template', () => { expect(restored.name).toBe(original.name); expect(restored.description).toBe(original.description); expect(restored.dontBringList).toEqual(original.dontBringList); + expect(restored.recommendedList).toEqual(original.recommendedList); expect(restored.defaultAnnouncement).toBe(original.defaultAnnouncement); expect(restored.createdAt).toEqual(original.createdAt); }); diff --git a/apps/api/src/contexts/templates/domain/template.ts b/apps/api/src/contexts/templates/domain/template.ts index e5b1affc..8066e7dd 100644 --- a/apps/api/src/contexts/templates/domain/template.ts +++ b/apps/api/src/contexts/templates/domain/template.ts @@ -5,6 +5,7 @@ export interface CreateTemplateProps { name: string; description: string; dontBringList: string[]; + recommendedList?: string[]; defaultAnnouncement: string | null; } @@ -13,6 +14,7 @@ export interface TemplateSnapshot { name: string; description: string; dontBringList: string[]; + recommendedList: string[]; defaultAnnouncement: string | null; createdAt: Date; } @@ -23,6 +25,7 @@ export class Template { public readonly name: string, public readonly description: string, public readonly dontBringList: string[], + public readonly recommendedList: string[], public readonly defaultAnnouncement: string | null, public readonly createdAt: Date, ) {} @@ -33,6 +36,7 @@ export class Template { props.name, props.description, props.dontBringList, + props.recommendedList ?? [], props.defaultAnnouncement, new Date(), ); @@ -44,6 +48,7 @@ export class Template { snap.name, snap.description, snap.dontBringList, + snap.recommendedList, snap.defaultAnnouncement, snap.createdAt, ); @@ -55,6 +60,7 @@ export class Template { name: this.name, description: this.description, dontBringList: this.dontBringList, + recommendedList: this.recommendedList, defaultAnnouncement: this.defaultAnnouncement, createdAt: this.createdAt, }; diff --git a/apps/api/src/contexts/templates/infrastructure/drizzle/drizzle-template.repository.int-spec.ts b/apps/api/src/contexts/templates/infrastructure/drizzle/drizzle-template.repository.int-spec.ts index fa9ce46a..5cb7eca4 100644 --- a/apps/api/src/contexts/templates/infrastructure/drizzle/drizzle-template.repository.int-spec.ts +++ b/apps/api/src/contexts/templates/infrastructure/drizzle/drizzle-template.repository.int-spec.ts @@ -34,6 +34,7 @@ describe('DrizzleTemplateRepository (integration)', () => { name: 'Terremoto básico', description: 'Template para terremotos', dontBringList: ['mascotas', 'joyas'], + recommendedList: ['agua', 'dieta líquida'], defaultAnnouncement: 'No traer mascotas', }); @@ -45,6 +46,7 @@ describe('DrizzleTemplateRepository (integration)', () => { expect(found?.name).toBe('Terremoto básico'); expect(found?.description).toBe('Template para terremotos'); expect(found?.dontBringList).toEqual(['mascotas', 'joyas']); + expect(found?.recommendedList).toEqual(['agua', 'dieta líquida']); expect(found?.defaultAnnouncement).toBe('No traer mascotas'); }); @@ -55,6 +57,7 @@ describe('DrizzleTemplateRepository (integration)', () => { name: 'T1', description: 'D1', dontBringList: [], + recommendedList: [], defaultAnnouncement: null, }), ); @@ -64,6 +67,7 @@ describe('DrizzleTemplateRepository (integration)', () => { name: 'T2', description: 'D2', dontBringList: ['x'], + recommendedList: ['y'], defaultAnnouncement: null, }), ); @@ -80,6 +84,7 @@ describe('DrizzleTemplateRepository (integration)', () => { name: 'To Delete', description: 'Desc', dontBringList: [], + recommendedList: [], defaultAnnouncement: null, }), ); diff --git a/apps/api/src/contexts/templates/infrastructure/drizzle/drizzle-template.repository.ts b/apps/api/src/contexts/templates/infrastructure/drizzle/drizzle-template.repository.ts index cceb39b5..f269903d 100644 --- a/apps/api/src/contexts/templates/infrastructure/drizzle/drizzle-template.repository.ts +++ b/apps/api/src/contexts/templates/infrastructure/drizzle/drizzle-template.repository.ts @@ -13,6 +13,7 @@ function rowToSnapshot(row: Row): TemplateSnapshot { name: row.name, description: row.description, dontBringList: row.dontBringList, + recommendedList: row.recommendedList, defaultAnnouncement: row.defaultAnnouncement ?? null, createdAt: row.createdAt, }; @@ -30,6 +31,7 @@ export class DrizzleTemplateRepository implements TemplateRepository { name: s.name, description: s.description, dontBringList: s.dontBringList, + recommendedList: s.recommendedList, defaultAnnouncement: s.defaultAnnouncement, createdAt: s.createdAt, }) @@ -39,6 +41,7 @@ export class DrizzleTemplateRepository implements TemplateRepository { name: s.name, description: s.description, dontBringList: s.dontBringList, + recommendedList: s.recommendedList, defaultAnnouncement: s.defaultAnnouncement, }, }); diff --git a/apps/api/src/contexts/templates/infrastructure/drizzle/schema.ts b/apps/api/src/contexts/templates/infrastructure/drizzle/schema.ts index 90718492..ff98e418 100644 --- a/apps/api/src/contexts/templates/infrastructure/drizzle/schema.ts +++ b/apps/api/src/contexts/templates/infrastructure/drizzle/schema.ts @@ -5,6 +5,7 @@ export const templatesTable = pgTable('templates', { name: text('name').notNull(), description: text('description').notNull(), dontBringList: text('dont_bring_list').array().notNull().default([]), + recommendedList: text('recommended_list').array().notNull().default([]), defaultAnnouncement: text('default_announcement'), createdAt: timestamp('created_at', { withTimezone: true }).notNull(), }); diff --git a/apps/api/src/contexts/templates/infrastructure/http/dto.ts b/apps/api/src/contexts/templates/infrastructure/http/dto.ts index c1b2d7da..7ded3bb7 100644 --- a/apps/api/src/contexts/templates/infrastructure/http/dto.ts +++ b/apps/api/src/contexts/templates/infrastructure/http/dto.ts @@ -29,6 +29,15 @@ export class CreateTemplateDto { @IsString({ each: true }) dontBringList!: string[]; + @ApiProperty({ + example: ['agua', 'dieta líquida', 'ítems EV'], + type: [String], + description: 'Items and priorities people SHOULD bring', + }) + @IsArray() + @IsString({ each: true }) + recommendedList!: string[]; + @ApiProperty({ example: 'No se aceptan mascotas en el centro de acopio.', nullable: true, @@ -57,6 +66,9 @@ export class TemplateViewDto { @ApiProperty({ example: ['mascotas', 'joyas'], type: [String] }) dontBringList!: string[]; + @ApiProperty({ example: ['agua', 'dieta líquida'], type: [String] }) + recommendedList!: string[]; + @ApiProperty({ example: 'No se aceptan mascotas.', nullable: true, diff --git a/apps/api/src/contexts/templates/infrastructure/http/templates.controller.ts b/apps/api/src/contexts/templates/infrastructure/http/templates.controller.ts index 5af82e1a..0ca8a5d2 100644 --- a/apps/api/src/contexts/templates/infrastructure/http/templates.controller.ts +++ b/apps/api/src/contexts/templates/infrastructure/http/templates.controller.ts @@ -64,6 +64,7 @@ export class TemplatesController { name: dto.name, description: dto.description, dontBringList: dto.dontBringList, + recommendedList: dto.recommendedList, ...(dto.defaultAnnouncement !== undefined && { defaultAnnouncement: dto.defaultAnnouncement, }), diff --git a/apps/web/src/app/admin/templates/actions.ts b/apps/web/src/app/admin/templates/actions.ts index f839894e..07ab074c 100644 --- a/apps/web/src/app/admin/templates/actions.ts +++ b/apps/web/src/app/admin/templates/actions.ts @@ -45,6 +45,7 @@ export async function createTemplateAction( const name = String(formData.get('name') ?? '').trim(); const description = String(formData.get('description') ?? '').trim(); const dontBringRaw = String(formData.get('dontBringList') ?? ''); + const recommendedRaw = String(formData.get('recommendedList') ?? ''); const defaultAnnouncement = String(formData.get('defaultAnnouncement') ?? '').trim() || null; @@ -59,6 +60,10 @@ export async function createTemplateAction( .split('\n') .map((l) => l.trim()) .filter((l) => l.length > 0); + const recommendedList = recommendedRaw + .split('\n') + .map((l) => l.trim()) + .filter((l) => l.length > 0); if (dontBringList.length === 0) { return { @@ -66,9 +71,15 @@ export async function createTemplateAction( message: t.templates.err_dont_bring_empty, }; } + if (recommendedList.length === 0) { + return { + status: 'error', + message: t.templates.err_recommended_empty, + }; + } const { error, response } = await api.POST('/templates', { - body: { name, description, dontBringList, defaultAnnouncement }, + body: { name, description, dontBringList, recommendedList, defaultAnnouncement }, headers: authHeaders(token), }); diff --git a/apps/web/src/app/admin/templates/create-template-form.tsx b/apps/web/src/app/admin/templates/create-template-form.tsx index b77e4759..d193c3cf 100644 --- a/apps/web/src/app/admin/templates/create-template-form.tsx +++ b/apps/web/src/app/admin/templates/create-template-form.tsx @@ -67,6 +67,19 @@ export function CreateTemplateForm() { /> + +