From 8b16b9a2dc037f0e9ccf922f1f88a5ce1eee1d10 Mon Sep 17 00:00:00 2001 From: Jesus Reyes Date: Tue, 30 Jun 2026 07:46:29 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(supplies):=20enlace=20opcional=20Suppl?= =?UTF-8?q?yLine.supplyId=20al=20cat=C3=A1logo=20maestro?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Añade un campo opcional supplyId al value object SupplyLine (soft link al catálogo Supply de #228), sus columnas/mappers Drizzle compartidos y los DTOs de request/response. Migración 0043 agrega la columna nullable supply_id con FK a supplies(id) ON DELETE SET NULL en las 4 tablas de líneas (need_items, offer_items, resource_items, donation_intake_lines). Las líneas en jsonb (containers.lines, shipments.items) heredan el campo vía snapshot. name se conserva para filas legacy y la opción "Otro" (supplyId null). Refs #223 --- .../drizzle/0044_supply_line_supply_id.sql | 39 +++++++++++++ .../supplies/domain/supply-line.spec.ts | 43 +++++++++++++- .../contexts/supplies/domain/supply-line.ts | 17 ++++++ .../drizzle/supply-line-columns.spec.ts | 56 +++++++++++++++++++ .../drizzle/supply-line-columns.ts | 9 ++- .../infrastructure/http/supply-line.dto.ts | 20 +++++++ 6 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 apps/api/drizzle/0044_supply_line_supply_id.sql create mode 100644 apps/api/src/contexts/supplies/infrastructure/drizzle/supply-line-columns.spec.ts diff --git a/apps/api/drizzle/0044_supply_line_supply_id.sql b/apps/api/drizzle/0044_supply_line_supply_id.sql new file mode 100644 index 00000000..bb9d044f --- /dev/null +++ b/apps/api/drizzle/0044_supply_line_supply_id.sql @@ -0,0 +1,39 @@ +-- Soft link de cada línea de material al catálogo maestro (#223): columna +-- `supply_id` opcional (FK a supplies(id) ON DELETE SET NULL) en las 4 tablas +-- de líneas. `name` se conserva (legacy + "Otro"). Las líneas en jsonb +-- (containers.lines, shipments.items) heredan el campo vía SupplyLineSnapshot, +-- sin columna. +-- +-- Idempotente: ADD COLUMN IF NOT EXISTS + DROP CONSTRAINT IF EXISTS antes de +-- ADD CONSTRAINT (patrón del repo, p.ej. 0011), para reaplicar sin error. + +ALTER TABLE "need_items" ADD COLUMN IF NOT EXISTS "supply_id" uuid; +--> statement-breakpoint +ALTER TABLE "need_items" DROP CONSTRAINT IF EXISTS "need_items_supply_id_fkey"; +--> statement-breakpoint +ALTER TABLE "need_items" ADD CONSTRAINT "need_items_supply_id_fkey" + FOREIGN KEY ("supply_id") REFERENCES "supplies"("id") ON DELETE SET NULL; +--> statement-breakpoint + +ALTER TABLE "offer_items" ADD COLUMN IF NOT EXISTS "supply_id" uuid; +--> statement-breakpoint +ALTER TABLE "offer_items" DROP CONSTRAINT IF EXISTS "offer_items_supply_id_fkey"; +--> statement-breakpoint +ALTER TABLE "offer_items" ADD CONSTRAINT "offer_items_supply_id_fkey" + FOREIGN KEY ("supply_id") REFERENCES "supplies"("id") ON DELETE SET NULL; +--> statement-breakpoint + +ALTER TABLE "resource_items" ADD COLUMN IF NOT EXISTS "supply_id" uuid; +--> statement-breakpoint +ALTER TABLE "resource_items" DROP CONSTRAINT IF EXISTS "resource_items_supply_id_fkey"; +--> statement-breakpoint +ALTER TABLE "resource_items" ADD CONSTRAINT "resource_items_supply_id_fkey" + FOREIGN KEY ("supply_id") REFERENCES "supplies"("id") ON DELETE SET NULL; +--> statement-breakpoint + +ALTER TABLE "donation_intake_lines" ADD COLUMN IF NOT EXISTS "supply_id" uuid; +--> statement-breakpoint +ALTER TABLE "donation_intake_lines" DROP CONSTRAINT IF EXISTS "donation_intake_lines_supply_id_fkey"; +--> statement-breakpoint +ALTER TABLE "donation_intake_lines" ADD CONSTRAINT "donation_intake_lines_supply_id_fkey" + FOREIGN KEY ("supply_id") REFERENCES "supplies"("id") ON DELETE SET NULL; diff --git a/apps/api/src/contexts/supplies/domain/supply-line.spec.ts b/apps/api/src/contexts/supplies/domain/supply-line.spec.ts index 4efffcc1..ac3cce21 100644 --- a/apps/api/src/contexts/supplies/domain/supply-line.spec.ts +++ b/apps/api/src/contexts/supplies/domain/supply-line.spec.ts @@ -17,7 +17,7 @@ describe('SupplyLine', () => { expect(line.presentation).toBeNull(); }); - it('defaults unit and presentation to null when not provided', () => { + it('defaults unit, presentation and supplyId to null when not provided', () => { const line = SupplyLine.create({ name: 'Mantas', quantity: 5, @@ -27,6 +27,32 @@ describe('SupplyLine', () => { expect(line.unit).toBeNull(); expect(line.presentation).toBeNull(); + expect(line.supplyId).toBeNull(); + }); + + it('keeps the supplyId soft link when provided (#223)', () => { + const supplyId = 'cf8da6e3-7b91-52ff-8cf7-bbff50786c35'; + const line = SupplyLine.create({ + name: 'Agua potable (botellón 18L)', + quantity: 20, + unit: 'und', + category: Category.Water, + supplyId: ` ${supplyId} `, + }); + + expect(line.supplyId).toBe(supplyId); // recortado + }); + + it('treats an empty supplyId as null (la opción "Otro")', () => { + const line = SupplyLine.create({ + name: 'Cosa rara', + quantity: 1, + unit: null, + category: Category.Other, + supplyId: ' ', + }); + + expect(line.supplyId).toBeNull(); }); it('keeps the presentation when provided (health vertical, #61)', () => { @@ -102,7 +128,7 @@ describe('SupplyLine', () => { }, ); - it('round-trips through a snapshot (including presentation and expiresAt)', () => { + it('round-trips through a snapshot (including presentation, expiresAt and supplyId)', () => { const line = SupplyLine.create({ name: 'Budesonida', quantity: 5, @@ -110,10 +136,23 @@ describe('SupplyLine', () => { category: Category.Medicines, presentation: 'inhalador', expiresAt: '2026-07-01', + supplyId: 'cf8da6e3-7b91-52ff-8cf7-bbff50786c35', }); const restored = SupplyLine.fromSnapshot(line.toSnapshot()); expect(restored.toSnapshot()).toEqual(line.toSnapshot()); + expect(restored.supplyId).toBe('cf8da6e3-7b91-52ff-8cf7-bbff50786c35'); + }); + + it('rehydrates a legacy snapshot without supplyId as null (compat)', () => { + const restored = SupplyLine.fromSnapshot({ + name: 'Arroz', + quantity: 3, + unit: 'kg', + category: Category.Food, + }); + + expect(restored.supplyId).toBeNull(); }); }); diff --git a/apps/api/src/contexts/supplies/domain/supply-line.ts b/apps/api/src/contexts/supplies/domain/supply-line.ts index e5f12f89..0bf81724 100644 --- a/apps/api/src/contexts/supplies/domain/supply-line.ts +++ b/apps/api/src/contexts/supplies/domain/supply-line.ts @@ -28,6 +28,7 @@ export interface SupplyLineProps { category: Category; presentation?: string | null; expiresAt?: string | null; + supplyId?: string | null; } export interface SupplyLineSnapshot { @@ -39,6 +40,12 @@ export interface SupplyLineSnapshot { presentation?: string | null; /** Optional freshness date for the line, kept as an ISO date string. */ expiresAt?: string | null; + /** + * Optional soft link to the master catalogue `Supply` by id (#223). Kept + * alongside `name` (which stays for legacy rows and the "Otro" escape). Null + * when the line is free text / "Otro". + */ + supplyId?: string | null; } function normalizeDateOnly(value?: string | null): string | null { @@ -66,6 +73,12 @@ export class SupplyLineValidationError extends Error { } } +function normalizeOptionalId(value?: string | null): string | null { + if (value == null) return null; + const trimmed = value.trim(); + return trimmed === '' ? null : trimmed; +} + export class SupplyLine { readonly name: string; readonly quantity: number; @@ -73,6 +86,7 @@ export class SupplyLine { readonly category: Category; readonly presentation: string | null; readonly expiresAt: string | null; + readonly supplyId: string | null; private constructor(props: SupplyLineProps) { this.name = props.name; @@ -81,6 +95,7 @@ export class SupplyLine { this.category = props.category; this.presentation = props.presentation ?? null; this.expiresAt = normalizeDateOnly(props.expiresAt); + this.supplyId = normalizeOptionalId(props.supplyId); } static create(props: SupplyLineProps): SupplyLine { @@ -99,6 +114,7 @@ export class SupplyLine { category: props.category, presentation: props.presentation ?? null, expiresAt: normalizeDateOnly(props.expiresAt), + supplyId: normalizeOptionalId(props.supplyId), }); } @@ -114,6 +130,7 @@ export class SupplyLine { category: this.category, presentation: this.presentation, expiresAt: this.expiresAt, + supplyId: this.supplyId, }; } } diff --git a/apps/api/src/contexts/supplies/infrastructure/drizzle/supply-line-columns.spec.ts b/apps/api/src/contexts/supplies/infrastructure/drizzle/supply-line-columns.spec.ts new file mode 100644 index 00000000..02bf99a2 --- /dev/null +++ b/apps/api/src/contexts/supplies/infrastructure/drizzle/supply-line-columns.spec.ts @@ -0,0 +1,56 @@ +import { + rowToSupplyLineSnapshot, + supplyLineToColumns, + SupplyLineRow, +} from './supply-line-columns'; +import { Category } from '../../domain/category'; +import { SupplyLineSnapshot } from '../../domain/supply-line'; + +describe('supply-line columns mappers', () => { + const baseRow: SupplyLineRow = { + name: 'Agua embotellada', + quantity: 10, + unit: 'L', + category: 'water', + presentation: 'pack 6', + expiresAt: null, + supplyId: null, + }; + + it('round-trips supplyId from row to snapshot', () => { + const id = '11111111-1111-4111-8111-111111111111'; + const snap = rowToSupplyLineSnapshot({ ...baseRow, supplyId: id }); + expect(snap.supplyId).toBe(id); + }); + + it('exposes a null supplyId for a legacy free-text row', () => { + const snap = rowToSupplyLineSnapshot(baseRow); + expect(snap.supplyId).toBeNull(); + }); + + it('persists the supplyId of a cataloged snapshot', () => { + const id = '22222222-2222-4222-8222-222222222222'; + const snap: SupplyLineSnapshot = { + name: 'Agua embotellada', + quantity: 10, + unit: 'L', + category: Category.Water, + presentation: null, + expiresAt: null, + supplyId: id, + }; + expect(supplyLineToColumns(snap).supplyId).toBe(id); + }); + + it('writes a null supplyId when the snapshot omits the soft link', () => { + const snap: SupplyLineSnapshot = { + name: 'Otro', + quantity: 1, + unit: null, + category: Category.Other, + presentation: null, + expiresAt: null, + }; + expect(supplyLineToColumns(snap).supplyId).toBeNull(); + }); +}); diff --git a/apps/api/src/contexts/supplies/infrastructure/drizzle/supply-line-columns.ts b/apps/api/src/contexts/supplies/infrastructure/drizzle/supply-line-columns.ts index 675625fd..c04a486a 100644 --- a/apps/api/src/contexts/supplies/infrastructure/drizzle/supply-line-columns.ts +++ b/apps/api/src/contexts/supplies/infrastructure/drizzle/supply-line-columns.ts @@ -1,4 +1,4 @@ -import { integer, text, timestamp } from 'drizzle-orm/pg-core'; +import { integer, text, timestamp, uuid } from 'drizzle-orm/pg-core'; import { Category } from '../../domain/category'; import { SupplyLineSnapshot } from '../../domain/supply-line'; @@ -20,6 +20,10 @@ export function supplyLineColumns() { category: text('category').notNull(), presentation: text('presentation'), expiresAt: timestamp('expires_at', { withTimezone: true }), + // Soft link to the master catalogue `Supply` (#223). Nullable; the FK to + // supplies(id) ON DELETE SET NULL lives in the migration (not here) so the + // needs/offers/resources schemas don't import the supplies schema. + supplyId: uuid('supply_id'), }; } @@ -31,6 +35,7 @@ export interface SupplyLineRow { category: string; presentation: string | null; expiresAt: Date | null; + supplyId: string | null; } function supplyLineDateToDb(value: string | null | undefined): Date | null { @@ -53,6 +58,7 @@ export function rowToSupplyLineSnapshot( category: row.category as Category, presentation: row.presentation ?? null, expiresAt: supplyLineDateFromDb(row.expiresAt), + supplyId: row.supplyId ?? null, }; } @@ -65,5 +71,6 @@ export function supplyLineToColumns(line: SupplyLineSnapshot): SupplyLineRow { category: line.category, presentation: line.presentation ?? null, expiresAt: supplyLineDateToDb(line.expiresAt), + supplyId: line.supplyId ?? null, }; } diff --git a/apps/api/src/contexts/supplies/infrastructure/http/supply-line.dto.ts b/apps/api/src/contexts/supplies/infrastructure/http/supply-line.dto.ts index d40292f0..ea6d0675 100644 --- a/apps/api/src/contexts/supplies/infrastructure/http/supply-line.dto.ts +++ b/apps/api/src/contexts/supplies/infrastructure/http/supply-line.dto.ts @@ -5,6 +5,7 @@ import { IsOptional, IsPositive, IsString, + IsUUID, Matches, } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; @@ -58,6 +59,16 @@ export class SupplyLineDto { @IsOptional() @Matches(/^\d{4}-\d{2}-\d{2}$/) expiresAt?: string; + + @ApiPropertyOptional({ + format: 'uuid', + example: 'cf8da6e3-7b91-52ff-8cf7-bbff50786c35', + description: + 'Optional soft link to the master catalogue supply (#223). Omit / null for free text or the "Otro" escape.', + }) + @IsOptional() + @IsUUID() + supplyId?: string; } /** @@ -94,4 +105,13 @@ export class SupplyLineResponseDto { 'Optional freshness date for the line, expressed as an ISO date (YYYY-MM-DD).', }) expiresAt?: string | null; + + @ApiPropertyOptional({ + format: 'uuid', + nullable: true, + type: String, + example: 'cf8da6e3-7b91-52ff-8cf7-bbff50786c35', + description: 'Soft link to the master catalogue supply, or null (#223).', + }) + supplyId!: string | null; } From 996800bd14dcb9a0ee02e8c6f55d20955df09e90 Mon Sep 17 00:00:00 2001 From: Jesus Reyes Date: Tue, 30 Jun 2026 07:46:42 +0000 Subject: [PATCH 2/2] =?UTF-8?q?feat(needs,offers,resources,logistics):=20p?= =?UTF-8?q?ropaga=20supplyId=20por=20las=20l=C3=ADneas=20de=20material?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hila el soft link supplyId desde el request hasta el snapshot en cada superficie de línea: needs, offers (oferta + intake de donación), inventario de recursos, contenedores y envíos. La dedup de inventario distingue una línea catalogada de una de texto libre con el mismo nombre. Las vistas de respuesta exponen supplyId (null en filas legacy). Refs #223 --- .../logistics/application/shipment-view.ts | 2 + .../http/shipment.controller.ts | 1 + .../contexts/needs/application/create-need.ts | 3 + .../infrastructure/http/needs.controller.ts | 1 + .../contexts/offers/application/edit-offer.ts | 2 + .../application/get-donation-intake-by-id.ts | 2 + .../get-donation-intake-tracking.spec.ts | 1 + .../get-donation-intake-tracking.ts | 2 + .../offers/application/submit-offer.ts | 2 + .../src/contexts/offers/domain/intake-line.ts | 1 + .../http/donation-intakes.controller.ts | 1 + .../infrastructure/http/offers.controller.ts | 2 + .../application/register-resource.ts | 2 + .../resources/domain/resource.spec.ts | 56 +++++++++++++++++++ .../src/contexts/resources/domain/resource.ts | 3 +- .../drizzle-resource.repository.int-spec.ts | 3 + .../http/resources.controller.ts | 2 + .../supplies/application/container-view.ts | 2 + .../http/containers.controller.ts | 2 + 19 files changed, 89 insertions(+), 1 deletion(-) diff --git a/apps/api/src/contexts/logistics/application/shipment-view.ts b/apps/api/src/contexts/logistics/application/shipment-view.ts index 323dad7b..34c951f5 100644 --- a/apps/api/src/contexts/logistics/application/shipment-view.ts +++ b/apps/api/src/contexts/logistics/application/shipment-view.ts @@ -7,6 +7,7 @@ export interface ShipmentItemView { unit: string | null; category: string; presentation: string | null; + supplyId: string | null; } export interface ShipmentView { @@ -32,6 +33,7 @@ function toItemView(i: SupplyLineSnapshot): ShipmentItemView { unit: i.unit, category: i.category, presentation: i.presentation ?? null, + supplyId: i.supplyId ?? null, }; } diff --git a/apps/api/src/contexts/logistics/infrastructure/http/shipment.controller.ts b/apps/api/src/contexts/logistics/infrastructure/http/shipment.controller.ts index b9c96b49..004a1d21 100644 --- a/apps/api/src/contexts/logistics/infrastructure/http/shipment.controller.ts +++ b/apps/api/src/contexts/logistics/infrastructure/http/shipment.controller.ts @@ -191,6 +191,7 @@ export class ShipmentController { unit: i.unit ?? null, category: i.category, presentation: i.presentation ?? null, + supplyId: i.supplyId ?? null, })), containerIds: dto.containerIds ?? [], manifest: dto.manifest ?? null, diff --git a/apps/api/src/contexts/needs/application/create-need.ts b/apps/api/src/contexts/needs/application/create-need.ts index 76417f03..eabb94f0 100644 --- a/apps/api/src/contexts/needs/application/create-need.ts +++ b/apps/api/src/contexts/needs/application/create-need.ts @@ -21,6 +21,8 @@ export interface CreateNeedItemCommand { /** Presentation / route of administration (#61). Optional. */ presentation?: string | null; expiresAt?: string | null; + /** Soft link to the master catalogue supply (#223). Optional. */ + supplyId?: string | null; } export interface CreateNeedLocationCommand { @@ -81,6 +83,7 @@ export class CreateNeed { category: i.category, presentation: i.presentation ?? null, expiresAt: i.expiresAt ?? null, + supplyId: i.supplyId ?? null, }), ); diff --git a/apps/api/src/contexts/needs/infrastructure/http/needs.controller.ts b/apps/api/src/contexts/needs/infrastructure/http/needs.controller.ts index dbb043f1..947b09f9 100644 --- a/apps/api/src/contexts/needs/infrastructure/http/needs.controller.ts +++ b/apps/api/src/contexts/needs/infrastructure/http/needs.controller.ts @@ -152,6 +152,7 @@ export class NeedsController { category: i.category, presentation: i.presentation ?? null, expiresAt: i.expiresAt ?? null, + supplyId: i.supplyId ?? null, })), requiredSkill: dto.requiredSkill ?? null, skillSpecialty: dto.skillSpecialty ?? null, diff --git a/apps/api/src/contexts/offers/application/edit-offer.ts b/apps/api/src/contexts/offers/application/edit-offer.ts index c724b03b..3a37cc0f 100644 --- a/apps/api/src/contexts/offers/application/edit-offer.ts +++ b/apps/api/src/contexts/offers/application/edit-offer.ts @@ -18,6 +18,7 @@ export interface EditOfferItemCommand { unit: string | null; category: Category; presentation: string | null; + supplyId?: string | null; } export interface EditOfferCommand { @@ -60,6 +61,7 @@ export class EditOffer { unit: i.unit, category: i.category, presentation: i.presentation, + supplyId: i.supplyId ?? null, }), ); } diff --git a/apps/api/src/contexts/offers/application/get-donation-intake-by-id.ts b/apps/api/src/contexts/offers/application/get-donation-intake-by-id.ts index 343e762b..1af2ee9e 100644 --- a/apps/api/src/contexts/offers/application/get-donation-intake-by-id.ts +++ b/apps/api/src/contexts/offers/application/get-donation-intake-by-id.ts @@ -12,6 +12,7 @@ export interface DonationIntakeLineView { category: Category; presentation: string | null; expiresAt: string | null; + supplyId: string | null; } export interface DonationIntakeView { @@ -62,6 +63,7 @@ export class GetDonationIntakeById { category: line.category, presentation: line.presentation ?? null, expiresAt: line.expiresAt ?? null, + supplyId: line.supplyId ?? null, })), volunteerNotes: snap.volunteerNotes, evidenceFileKey: snap.evidenceFileKey, diff --git a/apps/api/src/contexts/offers/application/get-donation-intake-tracking.spec.ts b/apps/api/src/contexts/offers/application/get-donation-intake-tracking.spec.ts index 5c60d677..f9e89fa2 100644 --- a/apps/api/src/contexts/offers/application/get-donation-intake-tracking.spec.ts +++ b/apps/api/src/contexts/offers/application/get-donation-intake-tracking.spec.ts @@ -86,6 +86,7 @@ describe('GetDonationIntakeTracking', () => { unit: 'l', category: Category.Water, presentation: null, + supplyId: null, }, ]); // No third-party PII leaks into the public view. diff --git a/apps/api/src/contexts/offers/application/get-donation-intake-tracking.ts b/apps/api/src/contexts/offers/application/get-donation-intake-tracking.ts index 150d9416..45b9f484 100644 --- a/apps/api/src/contexts/offers/application/get-donation-intake-tracking.ts +++ b/apps/api/src/contexts/offers/application/get-donation-intake-tracking.ts @@ -10,6 +10,7 @@ export interface DonationIntakeTrackingLine { unit: string | null; category: Category; presentation: string | null; + supplyId: string | null; } export interface DonationIntakeTracking { @@ -64,6 +65,7 @@ export class GetDonationIntakeTracking { unit: line.unit, category: line.category, presentation: line.presentation ?? null, + supplyId: line.supplyId ?? null, })), }; } diff --git a/apps/api/src/contexts/offers/application/submit-offer.ts b/apps/api/src/contexts/offers/application/submit-offer.ts index 16491d35..e6a3b5fd 100644 --- a/apps/api/src/contexts/offers/application/submit-offer.ts +++ b/apps/api/src/contexts/offers/application/submit-offer.ts @@ -41,6 +41,7 @@ export interface SubmitOfferItemCommand { unit: string | null; category: Category; presentation: string | null; + supplyId?: string | null; } export interface SubmitOfferCommand { @@ -100,6 +101,7 @@ export class SubmitOffer { unit: i.unit, category: i.category, presentation: i.presentation, + supplyId: i.supplyId ?? null, }), ); diff --git a/apps/api/src/contexts/offers/domain/intake-line.ts b/apps/api/src/contexts/offers/domain/intake-line.ts index af9c554f..ea455e19 100644 --- a/apps/api/src/contexts/offers/domain/intake-line.ts +++ b/apps/api/src/contexts/offers/domain/intake-line.ts @@ -43,6 +43,7 @@ export class IntakeLine { category: s.category, presentation: s.presentation ?? null, expiresAt: s.expiresAt ?? null, + supplyId: s.supplyId ?? null, }), ); } diff --git a/apps/api/src/contexts/offers/infrastructure/http/donation-intakes.controller.ts b/apps/api/src/contexts/offers/infrastructure/http/donation-intakes.controller.ts index 330e1dcd..007c0c77 100644 --- a/apps/api/src/contexts/offers/infrastructure/http/donation-intakes.controller.ts +++ b/apps/api/src/contexts/offers/infrastructure/http/donation-intakes.controller.ts @@ -82,6 +82,7 @@ function mapItems(items: CreateDonationIntakeDto['items']): SupplyLineProps[] { category: item.category, presentation: item.presentation ?? null, expiresAt: item.expiresAt ?? null, + supplyId: item.supplyId ?? null, })); } diff --git a/apps/api/src/contexts/offers/infrastructure/http/offers.controller.ts b/apps/api/src/contexts/offers/infrastructure/http/offers.controller.ts index 737c096d..9a8a6e9f 100644 --- a/apps/api/src/contexts/offers/infrastructure/http/offers.controller.ts +++ b/apps/api/src/contexts/offers/infrastructure/http/offers.controller.ts @@ -148,6 +148,7 @@ export class OffersController { unit: i.unit ?? null, category: i.category, presentation: i.presentation ?? null, + supplyId: i.supplyId ?? null, })), location: { address: dto.location.address, @@ -337,6 +338,7 @@ export class OffersController { unit: i.unit ?? null, category: i.category, presentation: i.presentation ?? null, + supplyId: i.supplyId ?? null, })); } if (dto.notes !== undefined) cmd.notes = dto.notes; diff --git a/apps/api/src/contexts/resources/application/register-resource.ts b/apps/api/src/contexts/resources/application/register-resource.ts index 38506f9a..22547f61 100644 --- a/apps/api/src/contexts/resources/application/register-resource.ts +++ b/apps/api/src/contexts/resources/application/register-resource.ts @@ -41,6 +41,7 @@ export interface RegisterResourceCommand { category: Category; presentation?: string | null; expiresAt?: string | null; + supplyId?: string | null; }>; /** Optional restricted author attribution (#235). */ author?: AuthorProps | null; @@ -89,6 +90,7 @@ export class RegisterResource { category: i.category, presentation: i.presentation ?? null, expiresAt: i.expiresAt ?? null, + supplyId: i.supplyId ?? null, }), ), author: cmd.author ? Author.create(cmd.author) : null, diff --git a/apps/api/src/contexts/resources/domain/resource.spec.ts b/apps/api/src/contexts/resources/domain/resource.spec.ts index 69a765dd..93617519 100644 --- a/apps/api/src/contexts/resources/domain/resource.spec.ts +++ b/apps/api/src/contexts/resources/domain/resource.spec.ts @@ -79,9 +79,65 @@ describe('Resource', () => { category: Category.Water, presentation: null, expiresAt: null, + supplyId: null, }); }); + it('carries the catalogue soft link (supplyId) through register → snapshot', () => { + const supplyId = '22222222-2222-4222-8222-222222222222'; + const r = Resource.register({ + id: ResourceId.create(), + emergencyId: EmergencyId.fromString( + '11111111-1111-4111-8111-111111111111', + ), + type: ResourceType.Warehouse, + stage: ResourceStage.Origin, + name: 'Almacén catalogado', + location: makeLocation(), + ownerUserId: 'user-abc-123', + items: [ + SupplyLine.create({ + name: 'Agua', + quantity: 100, + unit: 'litros', + category: Category.Water, + supplyId, + }), + ], + }); + + const restored = Resource.fromSnapshot(r.toSnapshot()); + expect(restored.items[0].supplyId).toBe(supplyId); + }); + + it('does not merge a cataloged line with a free-text line of the same name', () => { + const supplyId = '22222222-2222-4222-8222-222222222222'; + const r = make(); + r.receiveInventory([ + SupplyLine.create({ + name: 'Agua', + quantity: 5, + unit: 'l', + category: Category.Water, + supplyId, + }), + SupplyLine.create({ + name: 'Agua', + quantity: 3, + unit: 'l', + category: Category.Water, + }), + ]); + + expect(r.items).toHaveLength(2); + expect( + r.items.map((i) => ({ supplyId: i.supplyId, quantity: i.quantity })), + ).toEqual([ + { supplyId, quantity: 5 }, + { supplyId: null, quantity: 3 }, + ]); + }); + it('stores stage, location, ownerUserId and optional ownerOrganizationId', () => { const r = Resource.register({ id: ResourceId.create(), diff --git a/apps/api/src/contexts/resources/domain/resource.ts b/apps/api/src/contexts/resources/domain/resource.ts index 22fe1667..2ea487cd 100644 --- a/apps/api/src/contexts/resources/domain/resource.ts +++ b/apps/api/src/contexts/resources/domain/resource.ts @@ -329,7 +329,7 @@ export class Resource { receiveInventory(lines: SupplyLine[]): void { if (lines.length === 0) return; const keyOf = (l: SupplyLine): string => - JSON.stringify([l.name, l.category, l.unit, l.presentation]); + JSON.stringify([l.name, l.category, l.unit, l.presentation, l.supplyId]); const byKey = new Map(); for (const item of this._items) byKey.set(keyOf(item), item); for (const incoming of lines) { @@ -343,6 +343,7 @@ export class Resource { unit: incoming.unit, category: incoming.category, presentation: incoming.presentation, + supplyId: incoming.supplyId, }), ); } 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 9d9f6a9f..7b6da134 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 @@ -105,6 +105,7 @@ describe('DrizzleResourceRepository (integration)', () => { category: Category.Water, presentation: null, expiresAt: '2026-07-01', + supplyId: null, }, { name: 'Mantas', @@ -113,6 +114,7 @@ describe('DrizzleResourceRepository (integration)', () => { category: Category.Shelter, presentation: null, expiresAt: null, + supplyId: null, }, ]), ); @@ -143,6 +145,7 @@ describe('DrizzleResourceRepository (integration)', () => { category: Category.Food, presentation: null, expiresAt: null, + supplyId: null, }, ]); }); 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 002befd0..f39671c8 100644 --- a/apps/api/src/contexts/resources/infrastructure/http/resources.controller.ts +++ b/apps/api/src/contexts/resources/infrastructure/http/resources.controller.ts @@ -149,6 +149,7 @@ export class ResourcesController { category: i.category, presentation: i.presentation ?? null, expiresAt: i.expiresAt ?? null, + supplyId: i.supplyId ?? null, })), author: dto.author ?? null, }); @@ -187,6 +188,7 @@ export class ResourcesController { unit: i.unit ?? null, category: i.category, presentation: i.presentation ?? null, + supplyId: i.supplyId ?? null, })), }); } diff --git a/apps/api/src/contexts/supplies/application/container-view.ts b/apps/api/src/contexts/supplies/application/container-view.ts index 06b99317..ea5a09b4 100644 --- a/apps/api/src/contexts/supplies/application/container-view.ts +++ b/apps/api/src/contexts/supplies/application/container-view.ts @@ -7,6 +7,7 @@ export interface ContainerLineView { unit: string | null; category: string; presentation: string | null; + supplyId: string | null; } export interface ContainerView { @@ -43,6 +44,7 @@ function toLineView(l: SupplyLineSnapshot): ContainerLineView { unit: l.unit, category: l.category, presentation: l.presentation ?? null, + supplyId: l.supplyId ?? null, }; } diff --git a/apps/api/src/contexts/supplies/infrastructure/http/containers.controller.ts b/apps/api/src/contexts/supplies/infrastructure/http/containers.controller.ts index ab0f41a8..b253e0f1 100644 --- a/apps/api/src/contexts/supplies/infrastructure/http/containers.controller.ts +++ b/apps/api/src/contexts/supplies/infrastructure/http/containers.controller.ts @@ -187,6 +187,7 @@ export class ContainerController { unit: l.unit ?? null, category: l.category, presentation: l.presentation ?? null, + supplyId: l.supplyId ?? null, })), grossWeightKg: dto.grossWeightKg ?? null, grossVolumeM3: dto.grossVolumeM3 ?? null, @@ -220,6 +221,7 @@ export class ContainerController { unit: dto.unit ?? null, category: dto.category, presentation: dto.presentation ?? null, + supplyId: dto.supplyId ?? null, }, }); }