diff --git a/apps/api/drizzle/0045_supply_line_supply_id.sql b/apps/api/drizzle/0045_supply_line_supply_id.sql new file mode 100644 index 00000000..f79b5ba4 --- /dev/null +++ b/apps/api/drizzle/0045_supply_line_supply_id.sql @@ -0,0 +1,14 @@ +-- Soft link from each supply line to the canonical supplies master data. +-- Nullable and legacy-safe: existing rows can stay unlinked. + +ALTER TABLE "need_items" + ADD COLUMN IF NOT EXISTS "supply_id" uuid REFERENCES "supplies"("id") ON DELETE SET NULL; + +ALTER TABLE "offer_items" + ADD COLUMN IF NOT EXISTS "supply_id" uuid REFERENCES "supplies"("id") ON DELETE SET NULL; + +ALTER TABLE "resource_items" + ADD COLUMN IF NOT EXISTS "supply_id" uuid REFERENCES "supplies"("id") ON DELETE SET NULL; + +ALTER TABLE "donation_intake_lines" + ADD COLUMN IF NOT EXISTS "supply_id" uuid REFERENCES "supplies"("id") ON DELETE SET NULL; diff --git a/apps/api/src/contexts/logistics/infrastructure/logistics.module.ts b/apps/api/src/contexts/logistics/infrastructure/logistics.module.ts index e777afd8..3ba5b718 100644 --- a/apps/api/src/contexts/logistics/infrastructure/logistics.module.ts +++ b/apps/api/src/contexts/logistics/infrastructure/logistics.module.ts @@ -1,6 +1,6 @@ import { Inject, Module, OnModuleDestroy } from '@nestjs/common'; import { Queue } from 'bullmq'; -import IORedis from 'ioredis'; +import IORedis, { type Redis as IORedisConnection } from 'ioredis'; import { DB, DatabaseModule } from '../../../shared/database.module'; import { Db } from '../../../shared/db'; import { LogisticsController } from './http/logistics.controller'; @@ -50,6 +50,7 @@ import { DrizzleShipmentAuthorizationLookup } from './drizzle/drizzle-shipment-a import { DrizzleCapacityEmergencyLookup } from './drizzle/drizzle-capacity-emergency-lookup'; import { DrizzleResourceLocationLookup } from './drizzle/drizzle-resource-location-lookup'; import { DrizzleEmergencyStatusReader } from '../../../shared/drizzle-emergency-status-reader'; +import { toBullMqConnection } from '../../../shared/bullmq-connection'; import { BullMqShipmentEventBus } from './bullmq-shipment-event-bus'; import { IdentityModule } from '../../identity/infrastructure/identity.module'; // MEMBERSHIP_REPOSITORY is exported by IdentityModule and consumed by the @@ -69,7 +70,7 @@ export const SHIPMENT_EVENT_QUEUE = Symbol('ShipmentEventQueue'); interface EventQueue { queue: Queue; - connection: IORedis; + connection: IORedisConnection; } const eventQueueProvider = { @@ -77,8 +78,12 @@ const eventQueueProvider = { useFactory: (): EventQueue => { const url = process.env.REDIS_URL; if (!url) throw new Error('REDIS_URL is required'); - const connection = new IORedis(url, { maxRetriesPerRequest: null }); - const queue = new Queue('domain-events', { connection }); + const connection: IORedisConnection = new IORedis(url, { + maxRetriesPerRequest: null, + }); + const queue = new Queue('domain-events', { + connection: toBullMqConnection(connection), + }); return { queue, connection }; }, }; diff --git a/apps/api/src/contexts/needs/application/create-need.ts b/apps/api/src/contexts/needs/application/create-need.ts index 76417f03..1ec22f93 100644 --- a/apps/api/src/contexts/needs/application/create-need.ts +++ b/apps/api/src/contexts/needs/application/create-need.ts @@ -18,6 +18,7 @@ export interface CreateNeedItemCommand { quantity: number; unit: string | null; category: Category; + supplyId?: string | null; /** Presentation / route of administration (#61). Optional. */ presentation?: string | null; expiresAt?: string | null; @@ -79,6 +80,7 @@ export class CreateNeed { quantity: i.quantity, unit: i.unit, category: i.category, + supplyId: i.supplyId ?? null, presentation: i.presentation ?? null, expiresAt: i.expiresAt ?? null, }), diff --git a/apps/api/src/contexts/needs/infrastructure/drizzle/schema.ts b/apps/api/src/contexts/needs/infrastructure/drizzle/schema.ts index 30a131ed..8d3c15dc 100644 --- a/apps/api/src/contexts/needs/infrastructure/drizzle/schema.ts +++ b/apps/api/src/contexts/needs/infrastructure/drizzle/schema.ts @@ -8,6 +8,7 @@ import { jsonb, } from 'drizzle-orm/pg-core'; import { supplyLineColumns } from '../../../supplies/infrastructure/drizzle/supply-line-columns'; +import { suppliesTable } from '../../../supplies/infrastructure/drizzle/schema'; import { AuthorSnapshot } from '../../../../shared/domain/author'; export const needsTable = pgTable('needs', { @@ -45,5 +46,9 @@ export const needItemsTable = pgTable('need_items', { needId: uuid('need_id') .notNull() .references(() => needsTable.id, { onDelete: 'cascade' }), - ...supplyLineColumns(), + ...supplyLineColumns( + uuid('supply_id').references(() => suppliesTable.id, { + onDelete: 'set 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..a55d16a7 100644 --- a/apps/api/src/contexts/needs/infrastructure/http/needs.controller.ts +++ b/apps/api/src/contexts/needs/infrastructure/http/needs.controller.ts @@ -150,6 +150,7 @@ export class NeedsController { quantity: i.quantity, unit: i.unit ?? null, category: i.category, + supplyId: i.supplyId ?? null, presentation: i.presentation ?? null, expiresAt: i.expiresAt ?? null, })), diff --git a/apps/api/src/contexts/needs/infrastructure/needs.module.ts b/apps/api/src/contexts/needs/infrastructure/needs.module.ts index 75fe1ab1..29a004dc 100644 --- a/apps/api/src/contexts/needs/infrastructure/needs.module.ts +++ b/apps/api/src/contexts/needs/infrastructure/needs.module.ts @@ -1,6 +1,6 @@ import { Inject, Module, OnModuleDestroy } from '@nestjs/common'; import { Queue } from 'bullmq'; -import IORedis from 'ioredis'; +import IORedis, { type Redis as IORedisConnection } from 'ioredis'; import { DB, DatabaseModule } from '../../../shared/database.module'; import { Db } from '../../../shared/db'; import { NeedsController } from './http/needs.controller'; @@ -45,12 +45,13 @@ import { VolunteerRepository } from '../../volunteers/domain/ports/volunteer.rep import { TaskRepository } from '../../volunteers/domain/ports/task.repository'; import { DrizzleVolunteerRepository } from '../../volunteers/infrastructure/drizzle/drizzle-volunteer.repository'; import { DrizzleTaskRepository } from '../../volunteers/infrastructure/drizzle/drizzle-task.repository'; +import { toBullMqConnection } from '../../../shared/bullmq-connection'; export const EVENT_QUEUE = Symbol('NeedsEventQueue'); interface EventQueue { queue: Queue; - connection: IORedis; + connection: IORedisConnection; } const eventQueueProvider = { @@ -58,8 +59,12 @@ const eventQueueProvider = { useFactory: (): EventQueue => { const url = process.env.REDIS_URL; if (!url) throw new Error('REDIS_URL is required'); - const connection = new IORedis(url, { maxRetriesPerRequest: null }); - const queue = new Queue('domain-events', { connection }); + const connection: IORedisConnection = new IORedis(url, { + maxRetriesPerRequest: null, + }); + const queue = new Queue('domain-events', { + connection: toBullMqConnection(connection), + }); return { queue, connection }; }, }; diff --git a/apps/api/src/contexts/offers/application/edit-offer.ts b/apps/api/src/contexts/offers/application/edit-offer.ts index c724b03b..1af261d7 100644 --- a/apps/api/src/contexts/offers/application/edit-offer.ts +++ b/apps/api/src/contexts/offers/application/edit-offer.ts @@ -17,6 +17,7 @@ export interface EditOfferItemCommand { quantity: number; unit: string | null; category: Category; + supplyId?: string | null; presentation: string | null; } @@ -59,6 +60,7 @@ export class EditOffer { quantity: i.quantity, unit: i.unit, category: i.category, + supplyId: i.supplyId ?? null, presentation: i.presentation, }), ); 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..9022027d 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 @@ -10,6 +10,7 @@ export interface DonationIntakeLineView { quantity: number; unit: string | null; category: Category; + supplyId: string | null; presentation: string | null; expiresAt: string | null; } @@ -60,6 +61,7 @@ export class GetDonationIntakeById { quantity: line.quantity, unit: line.unit, category: line.category, + supplyId: line.supplyId ?? null, presentation: line.presentation ?? null, expiresAt: line.expiresAt ?? null, })), 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..3451ffdc 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 @@ -85,6 +85,7 @@ describe('GetDonationIntakeTracking', () => { quantity: 5, unit: 'l', category: Category.Water, + supplyId: null, presentation: null, }, ]); 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..30a77c74 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 @@ -9,6 +9,7 @@ export interface DonationIntakeTrackingLine { quantity: number; unit: string | null; category: Category; + supplyId: string | null; presentation: string | null; } @@ -63,6 +64,7 @@ export class GetDonationIntakeTracking { quantity: line.quantity, unit: line.unit, category: line.category, + supplyId: line.supplyId ?? null, presentation: line.presentation ?? 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..93eebc7a 100644 --- a/apps/api/src/contexts/offers/application/submit-offer.ts +++ b/apps/api/src/contexts/offers/application/submit-offer.ts @@ -40,6 +40,7 @@ export interface SubmitOfferItemCommand { quantity: number; unit: string | null; category: Category; + supplyId?: string | null; presentation: string | null; } @@ -99,6 +100,7 @@ export class SubmitOffer { quantity: i.quantity, unit: i.unit, category: i.category, + supplyId: i.supplyId ?? null, presentation: i.presentation, }), ); diff --git a/apps/api/src/contexts/offers/domain/intake-line.ts b/apps/api/src/contexts/offers/domain/intake-line.ts index af9c554f..f85677da 100644 --- a/apps/api/src/contexts/offers/domain/intake-line.ts +++ b/apps/api/src/contexts/offers/domain/intake-line.ts @@ -33,18 +33,7 @@ export class IntakeLine { } static fromSnapshot(s: IntakeLineSnapshot): IntakeLine { - return new IntakeLine( - s.id, - s.sortOrder, - SupplyLine.fromSnapshot({ - name: s.name, - quantity: s.quantity, - unit: s.unit, - category: s.category, - presentation: s.presentation ?? null, - expiresAt: s.expiresAt ?? null, - }), - ); + return new IntakeLine(s.id, s.sortOrder, SupplyLine.fromSnapshot(s)); } toSnapshot(): IntakeLineSnapshot { diff --git a/apps/api/src/contexts/offers/infrastructure/drizzle/donation-intake-schema.ts b/apps/api/src/contexts/offers/infrastructure/drizzle/donation-intake-schema.ts index f7ed229d..cd30366b 100644 --- a/apps/api/src/contexts/offers/infrastructure/drizzle/donation-intake-schema.ts +++ b/apps/api/src/contexts/offers/infrastructure/drizzle/donation-intake-schema.ts @@ -1,5 +1,6 @@ import { pgTable, uuid, text, timestamp, integer } from 'drizzle-orm/pg-core'; import { supplyLineColumns } from '../../../supplies/infrastructure/drizzle/supply-line-columns'; +import { suppliesTable } from '../../../supplies/infrastructure/drizzle/schema'; export const donationIntakesTable = pgTable('donation_intakes', { id: uuid('id').primaryKey(), @@ -25,6 +26,10 @@ export const donationIntakeLinesTable = pgTable('donation_intake_lines', { intakeId: uuid('intake_id') .notNull() .references(() => donationIntakesTable.id, { onDelete: 'cascade' }), - ...supplyLineColumns(), + ...supplyLineColumns( + uuid('supply_id').references(() => suppliesTable.id, { + onDelete: 'set null', + }), + ), sortOrder: integer('sort_order').notNull().default(0), }); diff --git a/apps/api/src/contexts/offers/infrastructure/drizzle/schema.ts b/apps/api/src/contexts/offers/infrastructure/drizzle/schema.ts index 64091bd6..52fd0b23 100644 --- a/apps/api/src/contexts/offers/infrastructure/drizzle/schema.ts +++ b/apps/api/src/contexts/offers/infrastructure/drizzle/schema.ts @@ -7,6 +7,7 @@ import { jsonb, } from 'drizzle-orm/pg-core'; import { supplyLineColumns } from '../../../supplies/infrastructure/drizzle/supply-line-columns'; +import { suppliesTable } from '../../../supplies/infrastructure/drizzle/schema'; import { AuthorSnapshot } from '../../../../shared/domain/author'; export const offersTable = pgTable('offers', { @@ -37,5 +38,9 @@ export const offerItemsTable = pgTable('offer_items', { offerId: uuid('offer_id') .notNull() .references(() => offersTable.id, { onDelete: 'cascade' }), - ...supplyLineColumns(), + ...supplyLineColumns( + uuid('supply_id').references(() => suppliesTable.id, { + onDelete: 'set 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..a0388202 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 @@ -80,6 +80,7 @@ function mapItems(items: CreateDonationIntakeDto['items']): SupplyLineProps[] { quantity: item.quantity, unit: item.unit ?? null, category: item.category, + supplyId: item.supplyId ?? null, presentation: item.presentation ?? null, expiresAt: item.expiresAt ?? 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..6693d4d1 100644 --- a/apps/api/src/contexts/offers/infrastructure/http/offers.controller.ts +++ b/apps/api/src/contexts/offers/infrastructure/http/offers.controller.ts @@ -147,6 +147,7 @@ export class OffersController { quantity: i.quantity, unit: i.unit ?? null, category: i.category, + supplyId: i.supplyId ?? null, presentation: i.presentation ?? null, })), location: { @@ -336,6 +337,7 @@ export class OffersController { quantity: i.quantity, unit: i.unit ?? null, category: i.category, + supplyId: i.supplyId ?? null, presentation: i.presentation ?? null, })); } diff --git a/apps/api/src/contexts/offers/infrastructure/offers.module.ts b/apps/api/src/contexts/offers/infrastructure/offers.module.ts index 6dd4c8ae..89cca467 100644 --- a/apps/api/src/contexts/offers/infrastructure/offers.module.ts +++ b/apps/api/src/contexts/offers/infrastructure/offers.module.ts @@ -1,6 +1,6 @@ import { Inject, Module, OnModuleDestroy } from '@nestjs/common'; import { Queue } from 'bullmq'; -import IORedis from 'ioredis'; +import IORedis, { type Redis as IORedisConnection } from 'ioredis'; import { DB, DatabaseModule } from '../../../shared/database.module'; import { Db } from '../../../shared/db'; import { OffersController } from './http/offers.controller'; @@ -69,6 +69,7 @@ import { NotificationsPort, } from '../../notifications/domain/ports/notifications.port'; import { NotificationsModule } from '../../notifications/infrastructure/notifications.module'; +import { toBullMqConnection } from '../../../shared/bullmq-connection'; // MEMBERSHIP_REPOSITORY and OFFER_EMERGENCY_LOOKUP are exported by IdentityModule // and consumed by OffersController via @Inject — no factory needed here. @@ -76,7 +77,7 @@ export const OFFER_EVENT_QUEUE = Symbol('OffersEventQueue'); interface EventQueue { queue: Queue; - connection: IORedis; + connection: IORedisConnection; } const eventQueueProvider = { @@ -84,8 +85,12 @@ const eventQueueProvider = { useFactory: (): EventQueue => { const url = process.env.REDIS_URL; if (!url) throw new Error('REDIS_URL is required'); - const connection = new IORedis(url, { maxRetriesPerRequest: null }); - const queue = new Queue('domain-events', { connection }); + const connection: IORedisConnection = new IORedis(url, { + maxRetriesPerRequest: null, + }); + const queue = new Queue('domain-events', { + connection: toBullMqConnection(connection), + }); return { queue, connection }; }, }; diff --git a/apps/api/src/contexts/resources/application/register-resource.ts b/apps/api/src/contexts/resources/application/register-resource.ts index 38506f9a..5f7a7810 100644 --- a/apps/api/src/contexts/resources/application/register-resource.ts +++ b/apps/api/src/contexts/resources/application/register-resource.ts @@ -39,6 +39,7 @@ export interface RegisterResourceCommand { quantity: number; unit?: string | null; category: Category; + supplyId?: string | null; presentation?: string | null; expiresAt?: string | null; }>; @@ -87,6 +88,7 @@ export class RegisterResource { quantity: i.quantity, unit: i.unit ?? null, category: i.category, + supplyId: i.supplyId ?? null, presentation: i.presentation ?? null, expiresAt: i.expiresAt ?? 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..3a33a9d2 100644 --- a/apps/api/src/contexts/resources/domain/resource.spec.ts +++ b/apps/api/src/contexts/resources/domain/resource.spec.ts @@ -77,6 +77,7 @@ describe('Resource', () => { quantity: 100, unit: 'litros', category: Category.Water, + supplyId: null, presentation: null, expiresAt: null, }); diff --git a/apps/api/src/contexts/resources/infrastructure/donation-events.worker.ts b/apps/api/src/contexts/resources/infrastructure/donation-events.worker.ts index 3bf83626..9346de32 100644 --- a/apps/api/src/contexts/resources/infrastructure/donation-events.worker.ts +++ b/apps/api/src/contexts/resources/infrastructure/donation-events.worker.ts @@ -1,8 +1,9 @@ import { Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { Worker, Job } from 'bullmq'; -import IORedis from 'ioredis'; +import IORedis, { type Redis as IORedisConnection } from 'ioredis'; import { ReceiveDonationIntoInventory } from '../application/receive-donation-into-inventory'; import { SupplyLineSnapshot } from '../../supplies/domain/supply-line'; +import { toBullMqConnection } from '../../../shared/bullmq-connection'; interface DomainEventJobData { name: string; @@ -28,7 +29,7 @@ const DONATION_RECEIVED = 'donation_intake.received'; export class DonationEventsWorker implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(DonationEventsWorker.name); private worker: Worker | null = null; - private connection: IORedis | null = null; + private connection: IORedisConnection | null = null; constructor(private readonly receive: ReceiveDonationIntoInventory) {} @@ -36,11 +37,12 @@ export class DonationEventsWorker implements OnModuleInit, OnModuleDestroy { const url = process.env.REDIS_URL; if (!url) throw new Error('REDIS_URL is required'); // BullMQ workers need a dedicated connection with blocking commands enabled. - this.connection = new IORedis(url, { maxRetriesPerRequest: null }); + const connection = new IORedis(url, { maxRetriesPerRequest: null }); + this.connection = connection; this.worker = new Worker( 'domain-events', (job: Job) => this.handle(job), - { connection: this.connection }, + { connection: toBullMqConnection(connection) }, ); this.worker.on('failed', (job, err) => { this.logger.error( 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..f06c458e 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 @@ -103,6 +103,7 @@ describe('DrizzleResourceRepository (integration)', () => { quantity: 200, unit: 'litros', category: Category.Water, + supplyId: null, presentation: null, expiresAt: '2026-07-01', }, @@ -111,6 +112,7 @@ describe('DrizzleResourceRepository (integration)', () => { quantity: 50, unit: null, category: Category.Shelter, + supplyId: null, presentation: null, expiresAt: null, }, @@ -127,6 +129,7 @@ describe('DrizzleResourceRepository (integration)', () => { quantity: 30, unit: 'kg', category: Category.Food, + supplyId: null, presentation: null, expiresAt: null, }, @@ -141,6 +144,7 @@ describe('DrizzleResourceRepository (integration)', () => { quantity: 30, unit: 'kg', category: Category.Food, + supplyId: null, presentation: null, expiresAt: null, }, diff --git a/apps/api/src/contexts/resources/infrastructure/drizzle/schema.ts b/apps/api/src/contexts/resources/infrastructure/drizzle/schema.ts index 05bdf62d..19cbf96b 100644 --- a/apps/api/src/contexts/resources/infrastructure/drizzle/schema.ts +++ b/apps/api/src/contexts/resources/infrastructure/drizzle/schema.ts @@ -8,6 +8,7 @@ import { boolean, } from 'drizzle-orm/pg-core'; import { supplyLineColumns } from '../../../supplies/infrastructure/drizzle/supply-line-columns'; +import { suppliesTable } from '../../../supplies/infrastructure/drizzle/schema'; import { AuthorSnapshot } from '../../../../shared/domain/author'; export const resourcesTable = pgTable('resources', { @@ -79,5 +80,9 @@ export const resourceItemsTable = pgTable('resource_items', { resourceId: uuid('resource_id') .notNull() .references(() => resourcesTable.id, { onDelete: 'cascade' }), - ...supplyLineColumns(), + ...supplyLineColumns( + uuid('supply_id').references(() => suppliesTable.id, { + onDelete: 'set 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..a6088bb0 100644 --- a/apps/api/src/contexts/resources/infrastructure/http/resources.controller.ts +++ b/apps/api/src/contexts/resources/infrastructure/http/resources.controller.ts @@ -147,6 +147,7 @@ export class ResourcesController { quantity: i.quantity, unit: i.unit ?? null, category: i.category, + supplyId: i.supplyId ?? null, presentation: i.presentation ?? null, expiresAt: i.expiresAt ?? null, })), @@ -186,6 +187,7 @@ export class ResourcesController { quantity: i.quantity, unit: i.unit ?? null, category: i.category, + supplyId: i.supplyId ?? null, presentation: i.presentation ?? null, })), }); diff --git a/apps/api/src/contexts/resources/infrastructure/resources.module.ts b/apps/api/src/contexts/resources/infrastructure/resources.module.ts index 18d5ba65..d37aa8f3 100644 --- a/apps/api/src/contexts/resources/infrastructure/resources.module.ts +++ b/apps/api/src/contexts/resources/infrastructure/resources.module.ts @@ -1,6 +1,6 @@ import { Inject, Module, OnModuleDestroy } from '@nestjs/common'; import { Queue } from 'bullmq'; -import IORedis from 'ioredis'; +import IORedis, { type Redis as IORedisConnection } from 'ioredis'; import { DB, DatabaseModule } from '../../../shared/database.module'; import { Db } from '../../../shared/db'; import { ResourcesController } from './http/resources.controller'; @@ -69,12 +69,13 @@ import { ResourceValidityReportRepository, } from '../domain/ports/resource-validity-report.repository'; import { DrizzleResourceValidityReportRepository } from './drizzle/drizzle-resource-validity-report.repository'; +import { toBullMqConnection } from '../../../shared/bullmq-connection'; export const EVENT_QUEUE = Symbol('ResourcesEventQueue'); interface EventQueue { queue: Queue; - connection: IORedis; + connection: IORedisConnection; } const eventQueueProvider = { @@ -82,8 +83,12 @@ const eventQueueProvider = { useFactory: (): EventQueue => { const url = process.env.REDIS_URL; if (!url) throw new Error('REDIS_URL is required'); - const connection = new IORedis(url, { maxRetriesPerRequest: null }); - const queue = new Queue('domain-events', { connection }); + const connection: IORedisConnection = new IORedis(url, { + maxRetriesPerRequest: null, + }); + const queue = new Queue('domain-events', { + connection: toBullMqConnection(connection), + }); return { queue, connection }; }, }; 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..b84ad172 100644 --- a/apps/api/src/contexts/supplies/domain/supply-line.spec.ts +++ b/apps/api/src/contexts/supplies/domain/supply-line.spec.ts @@ -8,12 +8,14 @@ describe('SupplyLine', () => { quantity: 100, unit: 'litros', category: Category.Water, + supplyId: ' 1e4b5f3b-5c9c-4f77-8f50-3d2dbdc0c7d8 ', }); expect(line.name).toBe('Agua embotellada'); expect(line.quantity).toBe(100); expect(line.unit).toBe('litros'); expect(line.category).toBe(Category.Water); + expect(line.supplyId).toBe('1e4b5f3b-5c9c-4f77-8f50-3d2dbdc0c7d8'); expect(line.presentation).toBeNull(); }); @@ -26,6 +28,7 @@ describe('SupplyLine', () => { }); expect(line.unit).toBeNull(); + expect(line.supplyId).toBeNull(); expect(line.presentation).toBeNull(); }); @@ -102,12 +105,13 @@ describe('SupplyLine', () => { }, ); - it('round-trips through a snapshot (including presentation and expiresAt)', () => { + it('round-trips through a snapshot including the soft link', () => { const line = SupplyLine.create({ name: 'Budesonida', quantity: 5, unit: null, category: Category.Medicines, + supplyId: 'b3f0c6e0-0f51-4f34-8f2d-8d1f3c0c5b50', presentation: 'inhalador', expiresAt: '2026-07-01', }); @@ -116,4 +120,16 @@ describe('SupplyLine', () => { expect(restored.toSnapshot()).toEqual(line.toSnapshot()); }); + + it('round-trips a null soft link in snapshots', () => { + const line = SupplyLine.fromSnapshot({ + name: 'Agua', + quantity: 1, + unit: null, + category: Category.Food, + supplyId: null, + }); + + expect(line.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..80a8dadc 100644 --- a/apps/api/src/contexts/supplies/domain/supply-line.ts +++ b/apps/api/src/contexts/supplies/domain/supply-line.ts @@ -26,6 +26,7 @@ export interface SupplyLineProps { quantity: number; unit: string | null; category: Category; + supplyId?: string | null; presentation?: string | null; expiresAt?: string | null; } @@ -35,12 +36,20 @@ export interface SupplyLineSnapshot { quantity: number; unit: string | null; category: Category; + /** Soft link to the canonical supply master data, or null when absent. */ + supplyId: string | null; /** Optional (legacy-safe) presentation / route of administration (#61). */ presentation?: string | null; /** Optional freshness date for the line, kept as an ISO date string. */ expiresAt?: string | null; } +function normalizeOptionalText(value?: string | null): string | null { + if (value == null) return null; + const trimmed = value.trim(); + return trimmed === '' ? null : trimmed; +} + function normalizeDateOnly(value?: string | null): string | null { if (value == null) return null; const trimmed = value.trim(); @@ -71,6 +80,7 @@ export class SupplyLine { readonly quantity: number; readonly unit: string | null; readonly category: Category; + readonly supplyId: string | null; readonly presentation: string | null; readonly expiresAt: string | null; @@ -79,6 +89,7 @@ export class SupplyLine { this.quantity = props.quantity; this.unit = props.unit; this.category = props.category; + this.supplyId = normalizeOptionalText(props.supplyId); this.presentation = props.presentation ?? null; this.expiresAt = normalizeDateOnly(props.expiresAt); } @@ -97,8 +108,9 @@ export class SupplyLine { quantity: props.quantity, unit: props.unit ?? null, category: props.category, + supplyId: props.supplyId ?? null, presentation: props.presentation ?? null, - expiresAt: normalizeDateOnly(props.expiresAt), + expiresAt: props.expiresAt ?? null, }); } @@ -112,6 +124,7 @@ export class SupplyLine { quantity: this.quantity, unit: this.unit, category: this.category, + supplyId: this.supplyId, presentation: this.presentation, expiresAt: this.expiresAt, }; 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..e00b205b 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,10 +1,11 @@ -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'; /** * The shared Drizzle columns of a supply line — the canonical material line of - * the platform: name + quantity + unit + category + presentation + expiresAt. + * the platform: name + quantity + unit + category + supplyId + presentation + + * expiresAt. * Spread into every `*_items` child table (`need_items`, `resource_items`, * `offer_items`, `donation_intake_lines`) so the column set can never drift * across contexts. @@ -12,12 +13,13 @@ import { SupplyLineSnapshot } from '../../domain/supply-line'; * A factory (not a shared constant) so each table gets its own fresh column * builders. */ -export function supplyLineColumns() { +export function supplyLineColumns(supplyIdColumn: ReturnType) { return { name: text('name').notNull(), quantity: integer('quantity').notNull(), unit: text('unit'), category: text('category').notNull(), + supplyId: supplyIdColumn, presentation: text('presentation'), expiresAt: timestamp('expires_at', { withTimezone: true }), }; @@ -29,6 +31,7 @@ export interface SupplyLineRow { quantity: number; unit: string | null; category: string; + supplyId: string | null; presentation: string | null; expiresAt: Date | null; } @@ -51,6 +54,7 @@ export function rowToSupplyLineSnapshot( quantity: row.quantity, unit: row.unit ?? null, category: row.category as Category, + supplyId: row.supplyId ?? null, presentation: row.presentation ?? null, expiresAt: supplyLineDateFromDb(row.expiresAt), }; @@ -63,6 +67,7 @@ export function supplyLineToColumns(line: SupplyLineSnapshot): SupplyLineRow { quantity: line.quantity, unit: line.unit, category: line.category, + supplyId: line.supplyId ?? null, presentation: line.presentation ?? null, expiresAt: supplyLineDateToDb(line.expiresAt), }; 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..9e4d05e8 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 @@ -4,6 +4,7 @@ import { IsNotEmpty, IsOptional, IsPositive, + IsUUID, IsString, Matches, } from 'class-validator'; @@ -23,6 +24,16 @@ export class SupplyLineDto { @IsNotEmpty() name!: string; + @ApiPropertyOptional({ + example: '1e4b5f3b-5c9c-4f77-8f50-3d2dbdc0c7d8', + nullable: true, + type: String, + description: 'Optional soft link to the canonical supply master data.', + }) + @IsOptional() + @IsUUID() + supplyId?: string | null; + @ApiProperty({ example: 100, description: 'Quantity (positive integer)' }) @IsInt() @IsPositive() @@ -67,6 +78,14 @@ export class SupplyLineResponseDto { @ApiProperty({ example: 'Water bottles' }) name!: string; + @ApiProperty({ + example: '1e4b5f3b-5c9c-4f77-8f50-3d2dbdc0c7d8', + nullable: true, + type: String, + description: 'Optional soft link to the canonical supply master data.', + }) + supplyId!: string | null; + @ApiProperty({ example: 100 }) quantity!: number; diff --git a/apps/api/src/shared/bullmq-connection.ts b/apps/api/src/shared/bullmq-connection.ts new file mode 100644 index 00000000..ef95313e --- /dev/null +++ b/apps/api/src/shared/bullmq-connection.ts @@ -0,0 +1,13 @@ +import type { Redis as IORedisConnection } from 'ioredis'; +import type { ConnectionOptions } from 'bullmq'; + +/** + * BullMQ's connection type is pinned through a different ioredis version in the + * dependency graph, so we centralize the compatibility cast here instead of + * repeating it at every call site. + */ +export function toBullMqConnection( + connection: IORedisConnection, +): ConnectionOptions { + return connection; +} diff --git a/apps/web/src/app/e/[slug]/donar/ofrecer/actions.ts b/apps/web/src/app/e/[slug]/donar/ofrecer/actions.ts index 0d589264..9bf4b452 100644 --- a/apps/web/src/app/e/[slug]/donar/ofrecer/actions.ts +++ b/apps/web/src/app/e/[slug]/donar/ofrecer/actions.ts @@ -38,6 +38,7 @@ export async function submitOffer( const rawDescription = formData.get('description'); const rawQuantity = formData.get('quantity'); const rawUnit = formData.get('unit'); + const rawSupplyId = formData.get('supplyId'); const rawAddress = formData.get('address'); const rawLatitude = formData.get('latitude'); const rawLongitude = formData.get('longitude'); @@ -88,6 +89,11 @@ export async function submitOffer( ? rawNotes.trim() : undefined; + const supplyId = + typeof rawSupplyId === 'string' && rawSupplyId.trim() !== '' + ? rawSupplyId.trim() + : undefined; + const donorOrganizationId = typeof rawOrgId === 'string' && rawOrgId.trim() !== '' ? rawOrgId.trim() @@ -112,6 +118,7 @@ export async function submitOffer( quantity: quantityRaw, category: rawCategory, ...(unit !== undefined ? { unit } : {}), + ...(supplyId !== undefined ? { supplyId } : {}), }, ], location: { address, latitude, longitude }, diff --git a/apps/web/src/app/e/[slug]/donar/ofrecer/donar-form.tsx b/apps/web/src/app/e/[slug]/donar/ofrecer/donar-form.tsx index 31851c4e..e6fcc2e6 100644 --- a/apps/web/src/app/e/[slug]/donar/ofrecer/donar-form.tsx +++ b/apps/web/src/app/e/[slug]/donar/ofrecer/donar-form.tsx @@ -14,6 +14,7 @@ import { DraftRestoredBanner } from '@/components/atoms/draft-restored-banner'; import { useFormDraft } from '@/lib/use-form-draft'; import { useLocale } from '@/i18n/locale-context'; import { MATERIAL_CATEGORIES, categoryLabel } from '@/lib/categories'; +import { SupplySelector } from '@/components/molecules/supply-selector'; import type { Messages } from '@/i18n/messages/es'; const INITIAL_STATE: OfferState = { status: 'idle' }; @@ -49,6 +50,7 @@ export function DonarForm({ const [category, setCategory] = useState(''); const [description, setDescription] = useState(''); + const [supplyId, setSupplyId] = useState(null); const [quantity, setQuantity] = useState(''); const [unit, setUnit] = useState(''); const [notes, setNotes] = useState(''); @@ -58,10 +60,11 @@ export function DonarForm({ ? `donar-${slug}-need-${targetNeedId}` : `donar-${slug}`; - const draftValues = { category, description, quantity, unit, notes }; + const draftValues = { category, description, supplyId, quantity, unit, notes }; const draftSetters = { category: setCategory, description: setDescription, + supplyId: setSupplyId, quantity: setQuantity, unit: setUnit, notes: setNotes, @@ -136,17 +139,20 @@ export function DonarForm({ htmlFor="description" label={<>{t.description_label} } > - setDescription(e.target.value)} + required + value={{ name: description, supplyId }} + onChange={(patch) => { + if (patch.name !== undefined) setDescription(patch.name); + if (patch.supplyId !== undefined) setSupplyId(patch.supplyId ?? null); + }} /> + +
diff --git a/apps/web/src/app/e/[slug]/peticion/items-field.tsx b/apps/web/src/app/e/[slug]/peticion/items-field.tsx index aec136dd..8764dd73 100644 --- a/apps/web/src/app/e/[slug]/peticion/items-field.tsx +++ b/apps/web/src/app/e/[slug]/peticion/items-field.tsx @@ -13,6 +13,7 @@ import { ALL_CATEGORIES } from '@/lib/categories'; interface Item { id: number; name: string; + supplyId: string | null; quantity: number; unit: string; category: string; @@ -21,7 +22,14 @@ interface Item { let nextId = 1; function makeItem(): Item { - return { id: nextId++, name: '', quantity: 1, unit: '', category: 'food' }; + return { + id: nextId++, + name: '', + supplyId: null, + quantity: 1, + unit: '', + category: 'food', + }; } interface ItemsFieldProps { @@ -50,8 +58,9 @@ export function ItemsField({ t }: ItemsFieldProps) { }; const serialized = JSON.stringify( - items.map(({ name, quantity, unit, category }) => ({ + items.map(({ name, supplyId, quantity, unit, category }) => ({ name, + ...(supplyId !== null ? { supplyId } : {}), quantity, ...(unit.trim() !== '' ? { unit: unit.trim() } : {}), category, diff --git a/apps/web/src/app/e/[slug]/registrar/inventory-field.tsx b/apps/web/src/app/e/[slug]/registrar/inventory-field.tsx index d287e5aa..0ae16d06 100644 --- a/apps/web/src/app/e/[slug]/registrar/inventory-field.tsx +++ b/apps/web/src/app/e/[slug]/registrar/inventory-field.tsx @@ -10,6 +10,7 @@ import { MATERIAL_CATEGORIES } from '@/lib/categories'; interface Item { id: number; name: string; + supplyId: string | null; quantity: number; unit: string; category: string; @@ -22,6 +23,7 @@ function makeItem(): Item { return { id: nextId++, name: '', + supplyId: null, quantity: 1, unit: '', category: MATERIAL_CATEGORIES[0], @@ -105,8 +107,9 @@ export function InventoryField({ const serialized = JSON.stringify( items .filter((i) => i.name.trim() !== '') - .map(({ name, quantity, unit, category, expiresAt }) => ({ + .map(({ name, supplyId, quantity, unit, category, expiresAt }) => ({ name: name.trim(), + ...(supplyId !== null ? { supplyId } : {}), quantity, ...(unit.trim() !== '' ? { unit: unit.trim() } : {}), category, diff --git a/apps/web/src/components/molecules/supply-line-row-fields.tsx b/apps/web/src/components/molecules/supply-line-row-fields.tsx index 272b9b53..43faf783 100644 --- a/apps/web/src/components/molecules/supply-line-row-fields.tsx +++ b/apps/web/src/components/molecules/supply-line-row-fields.tsx @@ -1,9 +1,11 @@ 'use client'; import { categoryLabel } from '@/lib/categories'; +import { SupplySelector } from '@/components/molecules/supply-selector'; export interface SupplyLineRowValue { name: string; + supplyId: string | null; quantity: number; unit: string; category: string; @@ -91,14 +93,13 @@ export function SupplyLineRowFields({ > {labels.nameLabel} - onChange({ name: e.target.value })} + locale={locale} placeholder={labels.namePlaceholder} - className="w-full rounded-lg border-2 border-navy bg-white px-4 py-3 text-base text-ink placeholder:text-muted-soft focus:outline-none focus:ring-2 focus:ring-navy focus:ring-offset-2" + required={required} + value={{ name: value.name, supplyId: value.supplyId }} + onChange={(patch) => onChange(patch)} />
diff --git a/apps/web/src/components/molecules/supply-selector.tsx b/apps/web/src/components/molecules/supply-selector.tsx new file mode 100644 index 00000000..e3c82b25 --- /dev/null +++ b/apps/web/src/components/molecules/supply-selector.tsx @@ -0,0 +1,227 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Input } from '@/components/atoms/input'; +import type { Locale } from '@/i18n'; + +interface SupplyOption { + id: string; + code: string; + name: string; + categoryLabel: string; +} + +export interface SupplySelectorValue { + name: string; + supplyId: string | null; +} + +interface SupplySelectorProps { + id: string; + locale: Locale; + placeholder: string; + required?: boolean; + value: SupplySelectorValue; + onChange: (patch: Partial) => void; +} + +type Copy = { + other: string; + hint: string; + loading: string; + empty: string; + error: string; +}; + +const COPY: Record = { + es: { + other: 'Otro', + hint: 'Busca por nombre, alias o código.', + loading: 'Buscando insumos…', + empty: 'No hay coincidencias. Usa “Otro” si no está en el catálogo.', + error: 'No pudimos cargar sugerencias.', + }, + en: { + other: 'Other', + hint: 'Search by name, alias, or code.', + loading: 'Searching supplies…', + empty: 'No matches. Use “Other” if it is not in the catalog.', + error: 'We could not load suggestions.', + }, +}; + +const API_URL = process.env.NEXT_PUBLIC_API_URL ?? ''; +const DEBOUNCE_MS = 220; + +export function SupplySelector({ + id, + locale, + placeholder, + required = false, + value, + onChange, +}: SupplySelectorProps) { + const copy = COPY[locale]; + const [results, setResults] = useState([]); + const [isOpen, setIsOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(false); + const debounceRef = useRef | null>(null); + const abortRef = useRef(null); + const containerRef = useRef(null); + const requestSeq = useRef(0); + + const fetchResults = useCallback( + async (term: string) => { + const trimmed = term.trim(); + if (trimmed.length < 2) { + setResults([]); + setError(false); + setLoading(false); + setIsOpen(false); + return; + } + + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + const seq = ++requestSeq.current; + setLoading(true); + setError(false); + try { + const params = new URLSearchParams({ + q: trimmed, + locale, + limit: '8', + }); + const response = await fetch(`${API_URL}/supplies?${params.toString()}`, { + signal: controller.signal, + }); + if (!response.ok) throw new Error('fetch failed'); + const data = (await response.json()) as SupplyOption[]; + if (seq !== requestSeq.current) return; + setResults(data); + setIsOpen(data.length > 0); + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') return; + if (seq !== requestSeq.current) return; + setResults([]); + setError(true); + setIsOpen(true); + } finally { + if (seq === requestSeq.current) setLoading(false); + } + }, + [locale], + ); + + useEffect(() => { + const handler = (event: MouseEvent) => { + if ( + containerRef.current !== null && + !containerRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, []); + + useEffect(() => { + return () => { + if (debounceRef.current !== null) clearTimeout(debounceRef.current); + }; + }, []); + + function chooseSupply(supply: SupplyOption) { + onChange({ name: supply.name, supplyId: supply.id }); + setIsOpen(false); + setResults([]); + } + + function handleInputChange(next: string) { + if (debounceRef.current !== null) clearTimeout(debounceRef.current); + setResults([]); + setError(false); + onChange({ name: next, supplyId: null }); + const hasEnough = next.trim().length >= 2; + setLoading(hasEnough); + setIsOpen(hasEnough); + debounceRef.current = setTimeout(() => { + void fetchResults(next); + }, DEBOUNCE_MS); + } + + function handleOther() { + onChange({ supplyId: null }); + setIsOpen(false); + } + + return ( +
+
+ handleInputChange(e.target.value)} + onFocus={() => { if (value.name.trim().length >= 2) setIsOpen(true); }} + placeholder={placeholder} + role="combobox" + aria-autocomplete="list" + aria-expanded={isOpen} + aria-haspopup="listbox" + aria-controls={`${id}-listbox`} + className="flex-1" + /> + +
+ +

{copy.hint}

+ + {isOpen && value.name.trim().length >= 2 && ( +
+ {loading ? ( +

{copy.loading}

+ ) : error ? ( +

{copy.error}

+ ) : results.length === 0 ? ( +

{copy.empty}

+ ) : ( +
    + {results.map((supply) => ( +
  • + +
  • + ))} +
+ )} +
+ )} +
+ ); +} diff --git a/apps/web/src/i18n/messages/en.ts b/apps/web/src/i18n/messages/en.ts index b9fe0b32..2d32cd2c 100644 --- a/apps/web/src/i18n/messages/en.ts +++ b/apps/web/src/i18n/messages/en.ts @@ -526,11 +526,11 @@ export const en = { choose_deliver_subtitle: 'Choose the point and pre-register your delivery — you get a code/QR for the desk', choose_offer_title: 'Offer supplies', - choose_offer_subtitle: 'Describe it and the coordination team handles it', + choose_offer_subtitle: 'Search the supply and complete the offer', directed_offer_label: 'Offering for:', category_label: 'Supply category', - description_label: 'Supply description', + description_label: 'Supply / item', description_placeholder: 'e.g. 25 kg rice bags', quantity_label: 'Quantity', quantity_placeholder: '50', @@ -551,7 +551,7 @@ export const en = { // server-action messages err_invalid_category: 'Invalid category.', - err_description_too_short: 'Describe the item (at least 2 characters).', + err_description_too_short: 'Specify the supply or item (at least 2 characters).', err_invalid_quantity: 'Quantity must be a positive whole number.', err_location_required: 'Select a location.', err_submit_failed: 'Couldn’t submit the offer. Please try again.', diff --git a/apps/web/src/i18n/messages/es.ts b/apps/web/src/i18n/messages/es.ts index cf9e91cb..1d36f2ab 100644 --- a/apps/web/src/i18n/messages/es.ts +++ b/apps/web/src/i18n/messages/es.ts @@ -550,11 +550,11 @@ export const es = { 'Elige el punto y pre-registra tu entrega — obtienes un código/QR para el mostrador', choose_offer_title: 'Ofrecer material', choose_offer_subtitle: - 'Lo describes y el equipo de coordinación lo gestiona', + 'Busca el insumo y completa la oferta', directed_offer_label: 'Ofreces para:', category_label: 'Categoría del material', - description_label: 'Descripción del material', + description_label: 'Insumo / material', description_placeholder: 'Ej. Sacos de arroz de 25 kg', quantity_label: 'Cantidad', quantity_placeholder: '50', @@ -575,7 +575,7 @@ export const es = { // server-action messages err_invalid_category: 'Categoría no válida.', - err_description_too_short: 'Describe el material (al menos 2 caracteres).', + err_description_too_short: 'Indica el insumo o material (al menos 2 caracteres).', err_invalid_quantity: 'La cantidad debe ser un número entero positivo.', err_location_required: 'Selecciona una ubicación.', err_submit_failed: 'Error al enviar la oferta. Inténtalo de nuevo.', diff --git a/apps/web/src/lib/supply-lines.test.ts b/apps/web/src/lib/supply-lines.test.ts index db527651..0ba38cb9 100644 --- a/apps/web/src/lib/supply-lines.test.ts +++ b/apps/web/src/lib/supply-lines.test.ts @@ -42,6 +42,25 @@ test('omits a blank unit instead of sending an empty string', () => { ]); }); +test('preserves a soft supply link when present', () => { + const raw = JSON.stringify([ + { + name: 'Agua', + quantity: 12, + category: 'water', + supplyId: ' 1e4b5f3b-5c9c-4f77-8f50-3d2dbdc0c7d8 ', + }, + ]); + assert.deepEqual(parseSupplyLines(raw, REQUIRED), [ + { + name: 'Agua', + quantity: 12, + category: 'water', + supplyId: '1e4b5f3b-5c9c-4f77-8f50-3d2dbdc0c7d8', + }, + ]); +}); + test('returns null on malformed JSON or a non-array root', () => { assert.equal(parseSupplyLines('{not json', REQUIRED), null); assert.equal(parseSupplyLines('{"a":1}', REQUIRED), null); diff --git a/apps/web/src/lib/supply-lines.ts b/apps/web/src/lib/supply-lines.ts index 5ec5cfe2..37577f2f 100644 --- a/apps/web/src/lib/supply-lines.ts +++ b/apps/web/src/lib/supply-lines.ts @@ -2,6 +2,9 @@ import type { components } from '@reliefhub/api-client'; type SupplyLineDto = components['schemas']['SupplyLineDto']; +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + /** * Parse the supply lines serialized by a SupplyLine editor (a JSON array in a * hidden `items` input) into the typed request shape. Returns `null` on a @@ -31,7 +34,7 @@ export function parseSupplyLines( const items: SupplyLineDto[] = []; for (const entry of parsed) { if (typeof entry !== 'object' || entry === null) return null; - const { name, quantity, unit, category, expiresAt } = entry as Record< + const { name, quantity, unit, category, supplyId, expiresAt } = entry as Record< string, unknown >; @@ -53,6 +56,13 @@ export function parseSupplyLines( ) { return null; } + if ( + supplyId !== undefined && + supplyId !== null && + (typeof supplyId !== 'string' || !UUID_RE.test(supplyId.trim())) + ) { + return null; + } items.push({ name: name.trim(), quantity, @@ -60,6 +70,9 @@ export function parseSupplyLines( ...(typeof unit === 'string' && unit.trim() !== '' ? { unit: unit.trim() } : {}), + ...(typeof supplyId === 'string' && supplyId.trim() !== '' + ? { supplyId: supplyId.trim() } + : {}), ...(typeof expiresAt === 'string' && expiresAt !== '' ? { expiresAt } : {}), diff --git a/packages/api-client/src/schema.ts b/packages/api-client/src/schema.ts index 82d10748..922f0c0f 100644 --- a/packages/api-client/src/schema.ts +++ b/packages/api-client/src/schema.ts @@ -2893,6 +2893,11 @@ export interface components { * @enum {string} */ category: "food" | "water" | "hygiene" | "clothing" | "medical" | "shelter" | "tools" | "other" | "medicines" | "medical_equipment" | "medical_supplies" | "medical_personnel" | "food_fresh" | "food_non_perishable" | "hygiene_infantile" | "hygiene_personal" | "tools_extraction" | "other_pets"; + /** + * @description Optional soft link to the canonical supply master data. + * @example 1e4b5f3b-5c9c-4f77-8f50-3d2dbdc0c7d8 + */ + supplyId?: string | null; /** * @description Presentation / route of administration: ampolla, EV (intravenoso), inhalador, pastilla, jarabe… Optional, free-form (#61). * @example ampolla @@ -3873,6 +3878,11 @@ export interface components { SupplyLineResponseDto: { /** @example Water bottles */ name: string; + /** + * @description Optional soft link to the canonical supply master data. + * @example 1e4b5f3b-5c9c-4f77-8f50-3d2dbdc0c7d8 + */ + supplyId: string | null; /** @example 100 */ quantity: number; /** @example liters */ @@ -4544,6 +4554,11 @@ export interface components { IntakeLineViewDto: { /** @example Water bottles */ name: string; + /** + * @description Optional soft link to the canonical supply master data. + * @example 1e4b5f3b-5c9c-4f77-8f50-3d2dbdc0c7d8 + */ + supplyId: string | null; /** @example 100 */ quantity: number; /** @example liters */