diff --git a/apps/api/drizzle/0059_resource_capacity_source.sql b/apps/api/drizzle/0059_resource_capacity_source.sql new file mode 100644 index 00000000..b3127f1a --- /dev/null +++ b/apps/api/drizzle/0059_resource_capacity_source.sql @@ -0,0 +1 @@ +ALTER TABLE resources ADD COLUMN capacity integer, ADD COLUMN occupancy integer, ADD COLUMN source_organisation text, ADD COLUMN source_url text; diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 46f4ddc9..5142d2a6 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -10257,6 +10257,26 @@ "example": "Caracas", "description": "City name" }, + "capacity": { + "type": "number", + "example": 200, + "description": "Total capacity of the place (e.g. shelter beds)" + }, + "occupancy": { + "type": "number", + "example": 120, + "description": "Current occupancy of the place" + }, + "sourceOrganisation": { + "type": "string", + "example": "Protección Civil", + "description": "Organisation that is the source of this record" + }, + "sourceUrl": { + "type": "string", + "example": "https://example.org/refugios/123", + "description": "URL of the source record" + }, "isFinalRecipient": { "type": "boolean", "example": true, @@ -10414,6 +10434,26 @@ "description": "Nuevo horario. Cadena vacía lo borra. Omitir para no cambiarlo.", "nullable": true }, + "capacity": { + "type": "number", + "description": "Nueva capacidad total. Omitir para no cambiarla.", + "example": 200 + }, + "occupancy": { + "type": "number", + "description": "Nueva ocupación actual. Omitir para no cambiarla.", + "example": 120 + }, + "sourceOrganisation": { + "type": "string", + "description": "Nueva organización de origen. Cadena vacía la borra. Omitir para no cambiarla.", + "nullable": true + }, + "sourceUrl": { + "type": "string", + "description": "Nueva URL de origen. Cadena vacía la borra. Omitir para no cambiarla.", + "nullable": true + }, "location": { "description": "Nueva ubicación (dirección + coordenadas) para corregir la geolocalización. Omitir para no cambiarla.", "allOf": [ @@ -10639,6 +10679,30 @@ "example": "Caracas", "nullable": true }, + "capacity": { + "type": "number", + "example": 200, + "nullable": true, + "description": "Total capacity of the place (e.g. shelter beds)" + }, + "occupancy": { + "type": "number", + "example": 120, + "nullable": true, + "description": "Current occupancy of the place" + }, + "sourceOrganisation": { + "type": "string", + "example": "Protección Civil", + "nullable": true, + "description": "Organisation that is the source of this record" + }, + "sourceUrl": { + "type": "string", + "example": "https://example.org/refugios/123", + "nullable": true, + "description": "URL of the source record" + }, "isFinalRecipient": { "type": "boolean", "example": false, @@ -10680,6 +10744,10 @@ "externalUpdatedAt", "country", "city", + "capacity", + "occupancy", + "sourceOrganisation", + "sourceUrl", "isFinalRecipient", "recipientType", "disputed", @@ -11015,6 +11083,30 @@ "example": "Caracas", "nullable": true }, + "capacity": { + "type": "number", + "example": 200, + "nullable": true, + "description": "Total capacity of the place (e.g. shelter beds)" + }, + "occupancy": { + "type": "number", + "example": 120, + "nullable": true, + "description": "Current occupancy of the place" + }, + "sourceOrganisation": { + "type": "string", + "example": "Protección Civil", + "nullable": true, + "description": "Organisation that is the source of this record" + }, + "sourceUrl": { + "type": "string", + "example": "https://example.org/refugios/123", + "nullable": true, + "description": "URL of the source record" + }, "isFinalRecipient": { "type": "boolean", "example": false, @@ -11061,6 +11153,10 @@ "externalUpdatedAt", "country", "city", + "capacity", + "occupancy", + "sourceOrganisation", + "sourceUrl", "isFinalRecipient", "recipientType", "disputed", @@ -11237,6 +11333,30 @@ "example": "Caracas", "nullable": true }, + "capacity": { + "type": "number", + "example": 200, + "nullable": true, + "description": "Total capacity of the place (e.g. shelter beds)" + }, + "occupancy": { + "type": "number", + "example": 120, + "nullable": true, + "description": "Current occupancy of the place" + }, + "sourceOrganisation": { + "type": "string", + "example": "Protección Civil", + "nullable": true, + "description": "Organisation that is the source of this record" + }, + "sourceUrl": { + "type": "string", + "example": "https://example.org/refugios/123", + "nullable": true, + "description": "URL of the source record" + }, "isFinalRecipient": { "type": "boolean", "example": false, @@ -11289,6 +11409,10 @@ "externalUpdatedAt", "country", "city", + "capacity", + "occupancy", + "sourceOrganisation", + "sourceUrl", "isFinalRecipient", "recipientType", "disputed", @@ -11444,6 +11568,30 @@ "example": "Caracas", "nullable": true }, + "capacity": { + "type": "number", + "example": 200, + "nullable": true, + "description": "Total capacity of the place (e.g. shelter beds)" + }, + "occupancy": { + "type": "number", + "example": 120, + "nullable": true, + "description": "Current occupancy of the place" + }, + "sourceOrganisation": { + "type": "string", + "example": "Protección Civil", + "nullable": true, + "description": "Organisation that is the source of this record" + }, + "sourceUrl": { + "type": "string", + "example": "https://example.org/refugios/123", + "nullable": true, + "description": "URL of the source record" + }, "isFinalRecipient": { "type": "boolean", "example": false, @@ -11507,6 +11655,10 @@ "externalUpdatedAt", "country", "city", + "capacity", + "occupancy", + "sourceOrganisation", + "sourceUrl", "isFinalRecipient", "recipientType", "disputed", @@ -11656,6 +11808,30 @@ "example": "Caracas", "nullable": true }, + "capacity": { + "type": "number", + "example": 200, + "nullable": true, + "description": "Total capacity of the place (e.g. shelter beds)" + }, + "occupancy": { + "type": "number", + "example": 120, + "nullable": true, + "description": "Current occupancy of the place" + }, + "sourceOrganisation": { + "type": "string", + "example": "Protección Civil", + "nullable": true, + "description": "Organisation that is the source of this record" + }, + "sourceUrl": { + "type": "string", + "example": "https://example.org/refugios/123", + "nullable": true, + "description": "URL of the source record" + }, "isFinalRecipient": { "type": "boolean", "example": false, @@ -11737,6 +11913,10 @@ "externalUpdatedAt", "country", "city", + "capacity", + "occupancy", + "sourceOrganisation", + "sourceUrl", "isFinalRecipient", "recipientType", "disputed", diff --git a/apps/api/src/contexts/resources/application/edit-resource.spec.ts b/apps/api/src/contexts/resources/application/edit-resource.spec.ts index 4c2dd267..39c49736 100644 --- a/apps/api/src/contexts/resources/application/edit-resource.spec.ts +++ b/apps/api/src/contexts/resources/application/edit-resource.spec.ts @@ -67,6 +67,42 @@ describe('EditResource', () => { expect(result.changes).toHaveLength(2); }); + it('edits capacity, occupancy, source organisation and source url', async () => { + const id = await seed(); + + const result = await editResource.execute({ + resourceId: id, + capacity: 200, + occupancy: 120, + sourceOrganisation: 'Protección Civil', + sourceUrl: 'https://example.org/refugios/123', + }); + + const resource = await repo.findById(ResourceId.fromString(id)); + expect(resource!.capacity).toBe(200); + expect(resource!.occupancy).toBe(120); + expect(resource!.sourceOrganisation).toBe('Protección Civil'); + expect(resource!.sourceUrl).toBe('https://example.org/refugios/123'); + + expect(result.changes).toEqual( + expect.arrayContaining([ + { field: 'capacity', before: null, after: 200 }, + { field: 'occupancy', before: null, after: 120 }, + { + field: 'sourceOrganisation', + before: null, + after: 'Protección Civil', + }, + { + field: 'sourceUrl', + before: null, + after: 'https://example.org/refugios/123', + }, + ]), + ); + expect(result.changes).toHaveLength(4); + }); + it('clears the contact when an empty string is given', async () => { const id = await seed(); await editResource.execute({ resourceId: id, contact: '+58 212 555' }); diff --git a/apps/api/src/contexts/resources/application/edit-resource.ts b/apps/api/src/contexts/resources/application/edit-resource.ts index 8411b36f..961e6c6e 100644 --- a/apps/api/src/contexts/resources/application/edit-resource.ts +++ b/apps/api/src/contexts/resources/application/edit-resource.ts @@ -14,6 +14,10 @@ export interface EditResourceCommand { description?: string | null; contact?: string | null; schedule?: string | null; + capacity?: number | null; + occupancy?: number | null; + sourceOrganisation?: string | null; + sourceUrl?: string | null; location?: LocationProps; } @@ -36,6 +40,10 @@ export class EditResource { description: resource.description, contact: resource.contact, schedule: resource.schedule, + capacity: resource.capacity, + occupancy: resource.occupancy, + sourceOrganisation: resource.sourceOrganisation, + sourceUrl: resource.sourceUrl, address: resource.location.toPlain().address, latitude: resource.location.toPlain().latitude, longitude: resource.location.toPlain().longitude, @@ -46,6 +54,11 @@ export class EditResource { if (cmd.description !== undefined) edit.description = cmd.description; if (cmd.contact !== undefined) edit.contact = cmd.contact; if (cmd.schedule !== undefined) edit.schedule = cmd.schedule; + if (cmd.capacity !== undefined) edit.capacity = cmd.capacity; + if (cmd.occupancy !== undefined) edit.occupancy = cmd.occupancy; + if (cmd.sourceOrganisation !== undefined) + edit.sourceOrganisation = cmd.sourceOrganisation; + if (cmd.sourceUrl !== undefined) edit.sourceUrl = cmd.sourceUrl; if (cmd.location !== undefined) edit.location = Location.create(cmd.location); resource.edit(edit); @@ -55,6 +68,10 @@ export class EditResource { description: resource.description, contact: resource.contact, schedule: resource.schedule, + capacity: resource.capacity, + occupancy: resource.occupancy, + sourceOrganisation: resource.sourceOrganisation, + sourceUrl: resource.sourceUrl, address: resource.location.toPlain().address, latitude: resource.location.toPlain().latitude, longitude: resource.location.toPlain().longitude, diff --git a/apps/api/src/contexts/resources/application/get-public-resources.spec.ts b/apps/api/src/contexts/resources/application/get-public-resources.spec.ts index 9bc10f7d..532cd73f 100644 --- a/apps/api/src/contexts/resources/application/get-public-resources.spec.ts +++ b/apps/api/src/contexts/resources/application/get-public-resources.spec.ts @@ -104,6 +104,10 @@ describe('GetPublicResources', () => { accepts: [], country: null, city: null, + capacity: null, + occupancy: null, + sourceOrganisation: null, + sourceUrl: null, provenance: null, isFinalRecipient: false, recipientType: null, diff --git a/apps/api/src/contexts/resources/application/get-resource-admin-detail.spec.ts b/apps/api/src/contexts/resources/application/get-resource-admin-detail.spec.ts index 8d8d06e3..db9bf3aa 100644 --- a/apps/api/src/contexts/resources/application/get-resource-admin-detail.spec.ts +++ b/apps/api/src/contexts/resources/application/get-resource-admin-detail.spec.ts @@ -35,6 +35,10 @@ function hiddenResourceWithInventory(): Resource { accepts: [], country: null, city: null, + capacity: null, + occupancy: null, + sourceOrganisation: null, + sourceUrl: null, provenance: null, isFinalRecipient: false, recipientType: null, diff --git a/apps/api/src/contexts/resources/application/ingest-external-resources.spec.ts b/apps/api/src/contexts/resources/application/ingest-external-resources.spec.ts index ec947c4e..d02fc91d 100644 --- a/apps/api/src/contexts/resources/application/ingest-external-resources.spec.ts +++ b/apps/api/src/contexts/resources/application/ingest-external-resources.spec.ts @@ -241,6 +241,10 @@ describe('IngestExternalResources', () => { accepts: [], country: null, city: null, + capacity: null, + occupancy: null, + sourceOrganisation: null, + sourceUrl: null, provenance: { sourceName: SOURCE_NAME, externalId: EXT_ID_1, @@ -304,6 +308,10 @@ describe('IngestExternalResources', () => { accepts: [], country: null, city: null, + capacity: null, + occupancy: null, + sourceOrganisation: null, + sourceUrl: null, provenance: { sourceName: SOURCE_NAME, externalId: EXT_ID_1, diff --git a/apps/api/src/contexts/resources/application/ingest-external-resources.ts b/apps/api/src/contexts/resources/application/ingest-external-resources.ts index 9caa49af..92bf2c06 100644 --- a/apps/api/src/contexts/resources/application/ingest-external-resources.ts +++ b/apps/api/src/contexts/resources/application/ingest-external-resources.ts @@ -87,6 +87,11 @@ export class IngestExternalResources { // Preserved (local-owned recipient role — #60): isFinalRecipient: existingSnap.isFinalRecipient, recipientType: existingSnap.recipientType, + // Preserved (local-owned enrichment — not mapped from the source): + capacity: existingSnap.capacity, + occupancy: existingSnap.occupancy, + sourceOrganisation: existingSnap.sourceOrganisation, + sourceUrl: existingSnap.sourceUrl, // Preserved (local-owned inventory — operators may have declared stock): items: existingSnap.items, // Preserved (citizen-validity dispute state — ficha 15): a re-ingest of @@ -141,6 +146,10 @@ export class IngestExternalResources { createdAt: new Date(), isFinalRecipient: false, recipientType: null, + capacity: null, + occupancy: null, + sourceOrganisation: null, + sourceUrl: null, items: [], contact: mapped.contact, schedule: mapped.schedule, diff --git a/apps/api/src/contexts/resources/application/redact-contact.spec.ts b/apps/api/src/contexts/resources/application/redact-contact.spec.ts index 650748ca..1d081bf9 100644 --- a/apps/api/src/contexts/resources/application/redact-contact.spec.ts +++ b/apps/api/src/contexts/resources/application/redact-contact.spec.ts @@ -25,6 +25,10 @@ function view(overrides: Partial = {}): ResourceView { externalUpdatedAt: null, country: null, city: null, + capacity: null, + occupancy: null, + sourceOrganisation: null, + sourceUrl: null, isFinalRecipient: false, recipientType: null, disputed: false, diff --git a/apps/api/src/contexts/resources/application/register-resource.ts b/apps/api/src/contexts/resources/application/register-resource.ts index bc539a87..0a38a661 100644 --- a/apps/api/src/contexts/resources/application/register-resource.ts +++ b/apps/api/src/contexts/resources/application/register-resource.ts @@ -27,6 +27,10 @@ export interface RegisterResourceCommand { accepts?: string[]; country?: string | null; city?: string | null; + capacity?: number | null; + occupancy?: number | null; + sourceOrganisation?: string | null; + sourceUrl?: string | null; provenance?: Provenance | null; // destinatario final (#60) isFinalRecipient?: boolean; @@ -77,6 +81,10 @@ export class RegisterResource { accepts: cmd.accepts ?? [], country: cmd.country ?? null, city: cmd.city ?? null, + capacity: cmd.capacity ?? null, + occupancy: cmd.occupancy ?? null, + sourceOrganisation: cmd.sourceOrganisation ?? null, + sourceUrl: cmd.sourceUrl ?? null, provenance: cmd.provenance ?? null, isFinalRecipient: cmd.isFinalRecipient ?? false, recipientType: cmd.recipientType ?? null, diff --git a/apps/api/src/contexts/resources/application/resource-view.ts b/apps/api/src/contexts/resources/application/resource-view.ts index 290d5b8b..65aab1ec 100644 --- a/apps/api/src/contexts/resources/application/resource-view.ts +++ b/apps/api/src/contexts/resources/application/resource-view.ts @@ -32,6 +32,10 @@ export interface ResourceView { /** Country string from the source's `pais` field — often a full Spanish name (e.g. "Venezuela"), NOT an ISO code. */ country: string | null; city: string | null; + capacity: number | null; + occupancy: number | null; + sourceOrganisation: string | null; + sourceUrl: string | null; // destinatario final (#60) isFinalRecipient: boolean; recipientType: string | null; @@ -75,6 +79,10 @@ export function toResourceView(r: Resource): ResourceView { externalUpdatedAt: r.provenance?.externalUpdatedAt?.toISOString() ?? null, country: r.country, city: r.city, + capacity: r.capacity, + occupancy: r.occupancy, + sourceOrganisation: r.sourceOrganisation, + sourceUrl: r.sourceUrl, isFinalRecipient: r.isFinalRecipient, recipientType: r.recipientType, disputed: r.disputed, diff --git a/apps/api/src/contexts/resources/domain/resource.spec.ts b/apps/api/src/contexts/resources/domain/resource.spec.ts index 9a513325..d26e51bc 100644 --- a/apps/api/src/contexts/resources/domain/resource.spec.ts +++ b/apps/api/src/contexts/resources/domain/resource.spec.ts @@ -315,6 +315,34 @@ describe('Resource', () => { expect(restored.provenance?.externalUpdatedAt).toEqual(externalUpdatedAt); expect(restored.provenance?.raw).toEqual({ x: 42 }); }); + + it('carries capacity/occupancy/source fields and defaults them to null', () => { + expect(make().capacity).toBeNull(); + expect(make().occupancy).toBeNull(); + expect(make().sourceOrganisation).toBeNull(); + expect(make().sourceUrl).toBeNull(); + + const r = Resource.register({ + id: ResourceId.create(), + emergencyId: EmergencyId.fromString( + '11111111-1111-4111-8111-111111111111', + ), + type: ResourceType.Venue, + name: 'Refugio con aforo', + location: makeLocation(), + ownerUserId: 'user-capacity', + capacity: 200, + occupancy: 120, + sourceOrganisation: 'Protección Civil', + sourceUrl: 'https://example.org/refugios/123', + }); + + const restored = Resource.fromSnapshot(r.toSnapshot()); + expect(restored.capacity).toBe(200); + expect(restored.occupancy).toBe(120); + expect(restored.sourceOrganisation).toBe('Protección Civil'); + expect(restored.sourceUrl).toBe('https://example.org/refugios/123'); + }); }); describe('destinatario final (#60)', () => { diff --git a/apps/api/src/contexts/resources/domain/resource.ts b/apps/api/src/contexts/resources/domain/resource.ts index b7d98bd3..a5648473 100644 --- a/apps/api/src/contexts/resources/domain/resource.ts +++ b/apps/api/src/contexts/resources/domain/resource.ts @@ -52,6 +52,10 @@ export interface RegisterResourceProps { accepts?: string[]; country?: string | null; city?: string | null; + capacity?: number | null; + occupancy?: number | null; + sourceOrganisation?: string | null; + sourceUrl?: string | null; provenance?: Provenance | null; // destinatario final (#60) isFinalRecipient?: boolean; @@ -68,6 +72,10 @@ export interface EditResourceProps { description?: string | null; contact?: string | null; schedule?: string | null; + capacity?: number | null; + occupancy?: number | null; + sourceOrganisation?: string | null; + sourceUrl?: string | null; /** Correct the point's geolocation (address + coordinates) in place (#—). */ location?: Location; } @@ -92,6 +100,10 @@ export interface ResourceSnapshot { accepts: string[]; country: string | null; city: string | null; + capacity: number | null; + occupancy: number | null; + sourceOrganisation: string | null; + sourceUrl: string | null; provenance: Provenance | null; isFinalRecipient: boolean; recipientType: string | null; @@ -131,6 +143,10 @@ export class Resource { public readonly accepts: string[], public readonly country: string | null, public readonly city: string | null, + private _capacity: number | null, + private _occupancy: number | null, + private _sourceOrganisation: string | null, + private _sourceUrl: string | null, public readonly provenance: Provenance | null, public readonly isFinalRecipient: boolean, public readonly recipientType: string | null, @@ -160,6 +176,10 @@ export class Resource { props.accepts ?? [], props.country ?? null, props.city ?? null, + props.capacity ?? null, + props.occupancy ?? null, + props.sourceOrganisation ?? null, + props.sourceUrl ?? null, props.provenance ?? null, props.isFinalRecipient ?? false, props.recipientType ?? null, @@ -198,6 +218,10 @@ export class Resource { s.accepts ?? [], s.country ?? null, s.city ?? null, + s.capacity ?? null, + s.occupancy ?? null, + s.sourceOrganisation ?? null, + s.sourceUrl ?? null, s.provenance ?? null, s.isFinalRecipient ?? false, s.recipientType ?? null, @@ -224,6 +248,18 @@ export class Resource { get schedule(): string | null { return this._schedule; } + get capacity(): number | null { + return this._capacity; + } + get occupancy(): number | null { + return this._occupancy; + } + get sourceOrganisation(): string | null { + return this._sourceOrganisation; + } + get sourceUrl(): string | null { + return this._sourceUrl; + } get verificationLevel(): VerificationLevel { return this._verificationLevel; } @@ -321,6 +357,22 @@ export class Resource { this._schedule = props.schedule === null ? null : props.schedule.trim() || null; } + if (props.capacity !== undefined) { + this._capacity = props.capacity; + } + if (props.occupancy !== undefined) { + this._occupancy = props.occupancy; + } + if (props.sourceOrganisation !== undefined) { + this._sourceOrganisation = + props.sourceOrganisation === null + ? null + : props.sourceOrganisation.trim() || null; + } + if (props.sourceUrl !== undefined) { + this._sourceUrl = + props.sourceUrl === null ? null : props.sourceUrl.trim() || null; + } if (props.location !== undefined) { this._location = props.location; } @@ -469,6 +521,10 @@ export class Resource { accepts: this.accepts, country: this.country, city: this.city, + capacity: this.capacity, + occupancy: this.occupancy, + sourceOrganisation: this.sourceOrganisation, + sourceUrl: this.sourceUrl, provenance: this.provenance, isFinalRecipient: this.isFinalRecipient, recipientType: this.recipientType, diff --git a/apps/api/src/contexts/resources/infrastructure/drizzle/drizzle-resource.repository.int-spec.ts b/apps/api/src/contexts/resources/infrastructure/drizzle/drizzle-resource.repository.int-spec.ts index 0ffb2cb9..e457f055 100644 --- a/apps/api/src/contexts/resources/infrastructure/drizzle/drizzle-resource.repository.int-spec.ts +++ b/apps/api/src/contexts/resources/infrastructure/drizzle/drizzle-resource.repository.int-spec.ts @@ -1509,6 +1509,10 @@ describe('DrizzleResourceRepository (integration)', () => { accepts: [], country: null, city: null, + capacity: null, + occupancy: null, + sourceOrganisation: null, + sourceUrl: null, provenance: null, isFinalRecipient: false, recipientType: null, @@ -1686,6 +1690,10 @@ describe('DrizzleResourceRepository (integration)', () => { accepts: [], country: null, city: null, + capacity: null, + occupancy: null, + sourceOrganisation: null, + sourceUrl: null, provenance: null, isFinalRecipient: false, recipientType: null, diff --git a/apps/api/src/contexts/resources/infrastructure/drizzle/drizzle-resource.repository.ts b/apps/api/src/contexts/resources/infrastructure/drizzle/drizzle-resource.repository.ts index 2ac22feb..106b925a 100644 --- a/apps/api/src/contexts/resources/infrastructure/drizzle/drizzle-resource.repository.ts +++ b/apps/api/src/contexts/resources/infrastructure/drizzle/drizzle-resource.repository.ts @@ -76,6 +76,10 @@ type RawRow = { external_updated_at: unknown; country: string | null; city: string | null; + capacity: unknown; + occupancy: unknown; + source_organisation: string | null; + source_url: string | null; raw: unknown; is_final_recipient: boolean | null; recipient_type: string | null; @@ -151,6 +155,10 @@ function rawRowToSnapshot(row: RawRow): ResourceSnapshot { accepts: toStringArray(row.accepts), country: row.country ?? null, city: row.city ?? null, + capacity: row.capacity == null ? null : Number(row.capacity), + occupancy: row.occupancy == null ? null : Number(row.occupancy), + sourceOrganisation: row.source_organisation ?? null, + sourceUrl: row.source_url ?? null, provenance, isFinalRecipient: row.is_final_recipient ?? false, recipientType: row.recipient_type ?? null, @@ -199,6 +207,10 @@ function rowToSnapshot(row: Row, items: ItemsRow[] = []): ResourceSnapshot { accepts: row.accepts ?? [], country: row.country ?? null, city: row.city ?? null, + capacity: row.capacity ?? null, + occupancy: row.occupancy ?? null, + sourceOrganisation: row.sourceOrganisation ?? null, + sourceUrl: row.sourceUrl ?? null, provenance: rowToProvenance(row), isFinalRecipient: row.isFinalRecipient ?? false, recipientType: row.recipientType ?? null, @@ -251,6 +263,10 @@ export class DrizzleResourceRepository implements ResourceRepository { accepts: s.accepts, country: s.country, city: s.city, + capacity: s.capacity, + occupancy: s.occupancy, + sourceOrganisation: s.sourceOrganisation, + sourceUrl: s.sourceUrl, sourceName: s.provenance?.sourceName ?? null, externalId: s.provenance?.externalId ?? null, externalUpdatedAt: s.provenance?.externalUpdatedAt ?? null, @@ -278,6 +294,10 @@ export class DrizzleResourceRepository implements ResourceRepository { accepts: s.accepts, country: s.country, city: s.city, + capacity: s.capacity, + occupancy: s.occupancy, + sourceOrganisation: s.sourceOrganisation, + sourceUrl: s.sourceUrl, sourceName: s.provenance?.sourceName ?? null, externalId: s.provenance?.externalId ?? null, externalUpdatedAt: s.provenance?.externalUpdatedAt ?? null, diff --git a/apps/api/src/contexts/resources/infrastructure/drizzle/schema.ts b/apps/api/src/contexts/resources/infrastructure/drizzle/schema.ts index c01668fd..0ec15738 100644 --- a/apps/api/src/contexts/resources/infrastructure/drizzle/schema.ts +++ b/apps/api/src/contexts/resources/infrastructure/drizzle/schema.ts @@ -6,6 +6,7 @@ import { doublePrecision, jsonb, boolean, + integer, } from 'drizzle-orm/pg-core'; import { supplyLineColumns } from '@globalemergency/warehouse-postgres'; import { suppliesTable } from '../../../supplies/infrastructure/drizzle/schema'; @@ -46,6 +47,11 @@ export const resourcesTable = pgTable('resources', { disputeDismissedAt: timestamp('dispute_dismissed_at', { withTimezone: true }), /** Restricted self-reported author attribution (#235). Never public. */ author: jsonb('author').$type(), + // enriched capacity + source fields (0059_resource_capacity_source) + capacity: integer('capacity'), + occupancy: integer('occupancy'), + sourceOrganisation: text('source_organisation'), + sourceUrl: text('source_url'), }); // Reportes ciudadanos de validez de un punto (0031_resource_validity_reports): diff --git a/apps/api/src/contexts/resources/infrastructure/http/dto.ts b/apps/api/src/contexts/resources/infrastructure/http/dto.ts index 7dc1a734..de15cc81 100644 --- a/apps/api/src/contexts/resources/infrastructure/http/dto.ts +++ b/apps/api/src/contexts/resources/infrastructure/http/dto.ts @@ -127,6 +127,41 @@ export class RegisterResourceDto { @IsString() city?: string; + @ApiPropertyOptional({ + example: 200, + description: 'Total capacity of the place (e.g. shelter beds)', + }) + @IsOptional() + @IsInt() + @Min(0) + capacity?: number; + + @ApiPropertyOptional({ + example: 120, + description: 'Current occupancy of the place', + }) + @IsOptional() + @IsInt() + @Min(0) + occupancy?: number; + + @ApiPropertyOptional({ + example: 'Protección Civil', + description: 'Organisation that is the source of this record', + }) + @IsOptional() + @IsString() + sourceOrganisation?: string; + + @ApiPropertyOptional({ + example: 'https://example.org/refugios/123', + description: 'URL of the source record', + }) + @IsOptional() + @IsString() + @MaxLength(2000) + sourceUrl?: string; + @ApiPropertyOptional({ example: true, description: 'Mark this resource as a final recipient of aid', @@ -556,6 +591,45 @@ export class EditResourceDto { @IsString() schedule?: string; + @ApiPropertyOptional({ + description: 'Nueva capacidad total. Omitir para no cambiarla.', + example: 200, + }) + @IsOptional() + @IsInt() + @Min(0) + capacity?: number; + + @ApiPropertyOptional({ + description: 'Nueva ocupación actual. Omitir para no cambiarla.', + example: 120, + }) + @IsOptional() + @IsInt() + @Min(0) + occupancy?: number; + + @ApiPropertyOptional({ + description: + 'Nueva organización de origen. Cadena vacía la borra. Omitir para no cambiarla.', + nullable: true, + type: String, + }) + @IsOptional() + @IsString() + sourceOrganisation?: string; + + @ApiPropertyOptional({ + description: + 'Nueva URL de origen. Cadena vacía la borra. Omitir para no cambiarla.', + nullable: true, + type: String, + }) + @IsOptional() + @IsString() + @MaxLength(2000) + sourceUrl?: string; + @ApiPropertyOptional({ description: 'Nueva ubicación (dirección + coordenadas) para corregir la geolocalización. Omitir para no cambiarla.', diff --git a/apps/api/src/contexts/resources/infrastructure/http/resources.controller.ts b/apps/api/src/contexts/resources/infrastructure/http/resources.controller.ts index 4ce62f50..a91f46ad 100644 --- a/apps/api/src/contexts/resources/infrastructure/http/resources.controller.ts +++ b/apps/api/src/contexts/resources/infrastructure/http/resources.controller.ts @@ -172,6 +172,10 @@ export class ResourcesController { accepts: dto.accepts ?? [], country: dto.country ?? null, city: dto.city ?? null, + capacity: dto.capacity ?? null, + occupancy: dto.occupancy ?? null, + sourceOrganisation: dto.sourceOrganisation ?? null, + sourceUrl: dto.sourceUrl ?? null, isFinalRecipient: dto.isFinalRecipient ?? false, recipientType: dto.recipientType ?? null, items: (dto.items ?? []).map(toSupplyLineProps), @@ -353,6 +357,11 @@ export class ResourcesController { if (dto.description !== undefined) cmd.description = dto.description; if (dto.contact !== undefined) cmd.contact = dto.contact; if (dto.schedule !== undefined) cmd.schedule = dto.schedule; + if (dto.capacity !== undefined) cmd.capacity = dto.capacity; + if (dto.occupancy !== undefined) cmd.occupancy = dto.occupancy; + if (dto.sourceOrganisation !== undefined) + cmd.sourceOrganisation = dto.sourceOrganisation; + if (dto.sourceUrl !== undefined) cmd.sourceUrl = dto.sourceUrl; if (dto.location !== undefined) cmd.location = dto.location; const result = await this.editResource.execute(cmd); diff --git a/apps/api/src/contexts/resources/infrastructure/http/response.dto.ts b/apps/api/src/contexts/resources/infrastructure/http/response.dto.ts index f759254f..300f1279 100644 --- a/apps/api/src/contexts/resources/infrastructure/http/response.dto.ts +++ b/apps/api/src/contexts/resources/infrastructure/http/response.dto.ts @@ -141,6 +141,38 @@ export class ResourceViewDto { @ApiProperty({ example: 'Caracas', nullable: true, type: String }) city!: string | null; + @ApiProperty({ + example: 200, + nullable: true, + type: Number, + description: 'Total capacity of the place (e.g. shelter beds)', + }) + capacity!: number | null; + + @ApiProperty({ + example: 120, + nullable: true, + type: Number, + description: 'Current occupancy of the place', + }) + occupancy!: number | null; + + @ApiProperty({ + example: 'Protección Civil', + nullable: true, + type: String, + description: 'Organisation that is the source of this record', + }) + sourceOrganisation!: string | null; + + @ApiProperty({ + example: 'https://example.org/refugios/123', + nullable: true, + type: String, + description: 'URL of the source record', + }) + sourceUrl!: string | null; + // ── destinatario final (#60) ────────────────────────────────────────────── @ApiProperty({ diff --git a/apps/api/src/contexts/resources/infrastructure/in-memory-resource.repository.spec.ts b/apps/api/src/contexts/resources/infrastructure/in-memory-resource.repository.spec.ts index 606ce763..39836023 100644 --- a/apps/api/src/contexts/resources/infrastructure/in-memory-resource.repository.spec.ts +++ b/apps/api/src/contexts/resources/infrastructure/in-memory-resource.repository.spec.ts @@ -53,6 +53,10 @@ const makeActiveUnverified = ( accepts: opts.accepts ?? [], country: opts.country ?? null, city: null, + capacity: null, + occupancy: null, + sourceOrganisation: null, + sourceUrl: null, provenance: null, isFinalRecipient: false, recipientType: null, diff --git a/packages/api-client/src/schema.ts b/packages/api-client/src/schema.ts index e6efcd02..b1dc7d7c 100644 --- a/packages/api-client/src/schema.ts +++ b/packages/api-client/src/schema.ts @@ -3246,6 +3246,26 @@ export interface components { * @example Caracas */ city?: string; + /** + * @description Total capacity of the place (e.g. shelter beds) + * @example 200 + */ + capacity?: number; + /** + * @description Current occupancy of the place + * @example 120 + */ + occupancy?: number; + /** + * @description Organisation that is the source of this record + * @example Protección Civil + */ + sourceOrganisation?: string; + /** + * @description URL of the source record + * @example https://example.org/refugios/123 + */ + sourceUrl?: string; /** * @description Mark this resource as a final recipient of aid * @example true @@ -3320,6 +3340,20 @@ export interface components { contact?: string | null; /** @description Nuevo horario. Cadena vacía lo borra. Omitir para no cambiarlo. */ schedule?: string | null; + /** + * @description Nueva capacidad total. Omitir para no cambiarla. + * @example 200 + */ + capacity?: number; + /** + * @description Nueva ocupación actual. Omitir para no cambiarla. + * @example 120 + */ + occupancy?: number; + /** @description Nueva organización de origen. Cadena vacía la borra. Omitir para no cambiarla. */ + sourceOrganisation?: string | null; + /** @description Nueva URL de origen. Cadena vacía la borra. Omitir para no cambiarla. */ + sourceUrl?: string | null; /** @description Nueva ubicación (dirección + coordenadas) para corregir la geolocalización. Omitir para no cambiarla. */ location?: components["schemas"]["LocationDto"]; }; @@ -3430,6 +3464,26 @@ export interface components { country: string | null; /** @example Caracas */ city: string | null; + /** + * @description Total capacity of the place (e.g. shelter beds) + * @example 200 + */ + capacity: number | null; + /** + * @description Current occupancy of the place + * @example 120 + */ + occupancy: number | null; + /** + * @description Organisation that is the source of this record + * @example Protección Civil + */ + sourceOrganisation: string | null; + /** + * @description URL of the source record + * @example https://example.org/refugios/123 + */ + sourceUrl: string | null; /** * @description Whether this resource is a final recipient of aid * @example false @@ -3608,6 +3662,26 @@ export interface components { country: string | null; /** @example Caracas */ city: string | null; + /** + * @description Total capacity of the place (e.g. shelter beds) + * @example 200 + */ + capacity: number | null; + /** + * @description Current occupancy of the place + * @example 120 + */ + occupancy: number | null; + /** + * @description Organisation that is the source of this record + * @example Protección Civil + */ + sourceOrganisation: string | null; + /** + * @description URL of the source record + * @example https://example.org/refugios/123 + */ + sourceUrl: string | null; /** * @description Whether this resource is a final recipient of aid * @example false @@ -3722,6 +3796,26 @@ export interface components { country: string | null; /** @example Caracas */ city: string | null; + /** + * @description Total capacity of the place (e.g. shelter beds) + * @example 200 + */ + capacity: number | null; + /** + * @description Current occupancy of the place + * @example 120 + */ + occupancy: number | null; + /** + * @description Organisation that is the source of this record + * @example Protección Civil + */ + sourceOrganisation: string | null; + /** + * @description URL of the source record + * @example https://example.org/refugios/123 + */ + sourceUrl: string | null; /** * @description Whether this resource is a final recipient of aid * @example false @@ -3827,6 +3921,26 @@ export interface components { country: string | null; /** @example Caracas */ city: string | null; + /** + * @description Total capacity of the place (e.g. shelter beds) + * @example 200 + */ + capacity: number | null; + /** + * @description Current occupancy of the place + * @example 120 + */ + occupancy: number | null; + /** + * @description Organisation that is the source of this record + * @example Protección Civil + */ + sourceOrganisation: string | null; + /** + * @description URL of the source record + * @example https://example.org/refugios/123 + */ + sourceUrl: string | null; /** * @description Whether this resource is a final recipient of aid * @example false @@ -3933,6 +4047,26 @@ export interface components { country: string | null; /** @example Caracas */ city: string | null; + /** + * @description Total capacity of the place (e.g. shelter beds) + * @example 200 + */ + capacity: number | null; + /** + * @description Current occupancy of the place + * @example 120 + */ + occupancy: number | null; + /** + * @description Organisation that is the source of this record + * @example Protección Civil + */ + sourceOrganisation: string | null; + /** + * @description URL of the source record + * @example https://example.org/refugios/123 + */ + sourceUrl: string | null; /** * @description Whether this resource is a final recipient of aid * @example false