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/drizzle-need.repository.int-spec.ts b/apps/api/src/contexts/needs/infrastructure/drizzle/drizzle-need.repository.int-spec.ts index 0147bdab..937031ea 100644 --- a/apps/api/src/contexts/needs/infrastructure/drizzle/drizzle-need.repository.int-spec.ts +++ b/apps/api/src/contexts/needs/infrastructure/drizzle/drizzle-need.repository.int-spec.ts @@ -1,6 +1,7 @@ import { eq } from 'drizzle-orm'; import { createDb, Db } from '../../../../shared/db'; import { needsTable, needItemsTable } from './schema'; +import { suppliesTable } from '../../../supplies/infrastructure/drizzle/schema'; import { DrizzleNeedRepository } from './drizzle-need.repository'; import { Need } from '../../domain/need'; import { NeedId } from '../../domain/need-id'; @@ -31,6 +32,7 @@ function makeItems() { quantity: 50, unit: 'liters', category: Category.Water, + supplyId: '1e4b5f3b-5c9c-4f77-8f50-3d2dbdc0c7d8', }), ]; } @@ -66,6 +68,19 @@ describe('DrizzleNeedRepository (integration)', () => { beforeEach(async () => { await db.delete(needItemsTable); await db.delete(needsTable); + await db + .insert(suppliesTable) + .values([ + { + id: '1e4b5f3b-5c9c-4f77-8f50-3d2dbdc0c7d8', + code: 'TEST-0001', + name: 'Agua Test', + categorySlug: 'water', + createdAt: new Date(), + updatedAt: new Date(), + }, + ]) + .onConflictDoNothing(); }); it('round-trips a need through Postgres (with items and location)', async () => { @@ -90,6 +105,9 @@ describe('DrizzleNeedRepository (integration)', () => { expect(found!.items[0].quantity).toBe(50); expect(found!.items[0].unit).toBe('liters'); expect(found!.items[0].category).toBe(Category.Water); + expect(found!.items[0].supplyId).toBe( + '1e4b5f3b-5c9c-4f77-8f50-3d2dbdc0c7d8', + ); }); it('round-trips the resourceId link to a final recipient (#60)', async () => { 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/drizzle-donation-intake.repository.int-spec.ts b/apps/api/src/contexts/offers/infrastructure/drizzle/drizzle-donation-intake.repository.int-spec.ts index 6742265e..2d66625f 100644 --- a/apps/api/src/contexts/offers/infrastructure/drizzle/drizzle-donation-intake.repository.int-spec.ts +++ b/apps/api/src/contexts/offers/infrastructure/drizzle/drizzle-donation-intake.repository.int-spec.ts @@ -3,6 +3,7 @@ import { donationIntakeLinesTable, donationIntakesTable, } from './donation-intake-schema'; +import { suppliesTable } from '../../../supplies/infrastructure/drizzle/schema'; import { DrizzleDonationIntakeRepository } from './drizzle-donation-intake.repository'; import { DonationIntake } from '../../domain/donation-intake'; import { DonationIntakeId } from '../../domain/donation-intake-id'; @@ -39,6 +40,7 @@ function makeIntake(code: string) { unit: 'sacos', presentation: null, expiresAt: '2026-07-01', + supplyId: '1e4b5f3b-5c9c-4f77-8f50-3d2dbdc0c7d8', }, }, ], @@ -62,6 +64,19 @@ describe('DrizzleDonationIntakeRepository (integration)', () => { beforeEach(async () => { await db.delete(donationIntakeLinesTable); await db.delete(donationIntakesTable); + await db + .insert(suppliesTable) + .values([ + { + id: '1e4b5f3b-5c9c-4f77-8f50-3d2dbdc0c7d8', + code: 'TEST-0001', + name: 'Agua Test', + categorySlug: 'water', + createdAt: new Date(), + updatedAt: new Date(), + }, + ]) + .onConflictDoNothing(); }); it('round-trips an intake with lines through Postgres', async () => { @@ -75,6 +90,9 @@ describe('DrizzleDonationIntakeRepository (integration)', () => { expect(found.lines).toHaveLength(1); expect(found.lines[0]?.supplyLine.name).toBe('Harina'); expect(found.lines[0]?.supplyLine.expiresAt).toBe('2026-07-01'); + expect(found.lines[0]?.supplyLine.supplyId).toBe( + '1e4b5f3b-5c9c-4f77-8f50-3d2dbdc0c7d8', + ); }); it('updates lines on save (replace)', async () => { diff --git a/apps/api/src/contexts/offers/infrastructure/drizzle/drizzle-offer.repository.int-spec.ts b/apps/api/src/contexts/offers/infrastructure/drizzle/drizzle-offer.repository.int-spec.ts index 80ca3544..7df025ed 100644 --- a/apps/api/src/contexts/offers/infrastructure/drizzle/drizzle-offer.repository.int-spec.ts +++ b/apps/api/src/contexts/offers/infrastructure/drizzle/drizzle-offer.repository.int-spec.ts @@ -1,5 +1,6 @@ import { createDb, Db } from '../../../../shared/db'; import { offersTable } from './schema'; +import { suppliesTable } from '../../../supplies/infrastructure/drizzle/schema'; import { DrizzleOfferRepository } from './drizzle-offer.repository'; import { DonationOffer } from '../../domain/donation-offer'; import { OfferId } from '../../domain/offer-id'; @@ -37,6 +38,7 @@ function makeOffer(overrides?: { category?: Category; name?: string }) { unit: 'bags', category: overrides?.category ?? Category.Food, presentation: null, + supplyId: '1e4b5f3b-5c9c-4f77-8f50-3d2dbdc0c7d8', }), ], location: makeLocation(), @@ -62,6 +64,19 @@ describe('DrizzleOfferRepository (integration)', () => { beforeEach(async () => { // FK cascade removes offer_items with their offers. await db.delete(offersTable); + await db + .insert(suppliesTable) + .values([ + { + id: '1e4b5f3b-5c9c-4f77-8f50-3d2dbdc0c7d8', + code: 'TEST-0001', + name: 'Agua Test', + categorySlug: 'water', + createdAt: new Date(), + updatedAt: new Date(), + }, + ]) + .onConflictDoNothing(); }); it('round-trips an offer (with its lines) through Postgres', async () => { @@ -78,6 +93,9 @@ describe('DrizzleOfferRepository (integration)', () => { expect(found!.items[0].name).toBe('Rice bags'); expect(found!.items[0].quantity).toBe(25); expect(found!.items[0].unit).toBe('bags'); + expect(found!.items[0].supplyId).toBe( + '1e4b5f3b-5c9c-4f77-8f50-3d2dbdc0c7d8', + ); expect(found!.location.address).toBe('Test St, Caracas'); expect(found!.location.latitude).toBe(10.4806); expect(found!.targetNeedId).toBeNull(); 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..fd19e64b 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 @@ -1,6 +1,7 @@ import { eq, sql } from 'drizzle-orm'; import { createDb, Db } from '../../../../shared/db'; import { resourcesTable } from './schema'; +import { suppliesTable } from '../../../supplies/infrastructure/drizzle/schema'; import { emergenciesTable } from '../../../emergencies/infrastructure/drizzle/schema'; import { DrizzleResourceRepository } from './drizzle-resource.repository'; import { Resource } from '../../domain/resource'; @@ -43,6 +44,27 @@ describe('DrizzleResourceRepository (integration)', () => { }); beforeEach(async () => { await db.delete(resourcesTable); + await db + .insert(suppliesTable) + .values([ + { + id: '1e4b5f3b-5c9c-4f77-8f50-3d2dbdc0c7d8', + code: 'TEST-0001', + name: 'Agua Test', + categorySlug: 'water', + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: '2e4b5f3b-5c9c-4f77-8f50-3d2dbdc0c7d9', + code: 'TEST-0002', + name: 'Arroz Test', + categorySlug: 'food', + createdAt: new Date(), + updatedAt: new Date(), + }, + ]) + .onConflictDoNothing(); }); it('round-trips an aggregate through Postgres', async () => { @@ -82,6 +104,7 @@ describe('DrizzleResourceRepository (integration)', () => { unit: 'litros', category: Category.Water, expiresAt: '2026-07-01', + supplyId: '1e4b5f3b-5c9c-4f77-8f50-3d2dbdc0c7d8', }), SupplyLine.create({ name: 'Mantas', @@ -103,6 +126,7 @@ describe('DrizzleResourceRepository (integration)', () => { quantity: 200, unit: 'litros', category: Category.Water, + supplyId: '1e4b5f3b-5c9c-4f77-8f50-3d2dbdc0c7d8', presentation: null, expiresAt: '2026-07-01', }, @@ -111,6 +135,7 @@ describe('DrizzleResourceRepository (integration)', () => { quantity: 50, unit: null, category: Category.Shelter, + supplyId: null, presentation: null, expiresAt: null, }, @@ -127,6 +152,7 @@ describe('DrizzleResourceRepository (integration)', () => { quantity: 30, unit: 'kg', category: Category.Food, + supplyId: '2e4b5f3b-5c9c-4f77-8f50-3d2dbdc0c7d9', presentation: null, expiresAt: null, }, @@ -141,6 +167,7 @@ describe('DrizzleResourceRepository (integration)', () => { quantity: 30, unit: 'kg', category: Category.Food, + supplyId: '2e4b5f3b-5c9c-4f77-8f50-3d2dbdc0c7d9', 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/application/list-supplies.spec.ts b/apps/api/src/contexts/supplies/application/list-supplies.spec.ts index 555b1c04..de67ecad 100644 --- a/apps/api/src/contexts/supplies/application/list-supplies.spec.ts +++ b/apps/api/src/contexts/supplies/application/list-supplies.spec.ts @@ -62,4 +62,40 @@ describe('ListSupplies', () => { expect(result).toHaveLength(1); expect(result[0]?.id).toBe(catalog[0].id); }); + + it('soporta búsquedas difusas (fuzzy) con errores tipográficos (ej: abua -> agua)', async () => { + const result = await new ListSupplies(readModel()).execute({ + q: 'abua', + locale: 'es', + limit: 20, + offset: 0, + }); + + expect(result).toHaveLength(1); + expect(result[0]?.nameEs).toBe('Agua potable'); + }); + + it('soporta búsquedas por subcadenas o partes de palabra (ej: gua -> agua)', async () => { + const result = await new ListSupplies(readModel()).execute({ + q: 'gua', + locale: 'es', + limit: 20, + offset: 0, + }); + + expect(result).toHaveLength(1); + expect(result[0]?.nameEs).toBe('Agua potable'); + }); + + it('ordena los resultados de forma que los mejores matches (exacto/prefijo) salgan primero', async () => { + const result = await new ListSupplies(readModel()).execute({ + q: 'agua', + locale: 'es', + limit: 20, + offset: 0, + }); + + expect(result).toHaveLength(1); + expect(result[0]?.nameEs).toBe('Agua potable'); + }); }); diff --git a/apps/api/src/contexts/supplies/application/list-supplies.ts b/apps/api/src/contexts/supplies/application/list-supplies.ts index 4c63c844..6f2ffbe9 100644 --- a/apps/api/src/contexts/supplies/application/list-supplies.ts +++ b/apps/api/src/contexts/supplies/application/list-supplies.ts @@ -15,6 +15,82 @@ export interface SupplyCatalogQuery { offset: number; } +function levenshtein(a: string, b: string): number { + const tmp: number[][] = []; + for (let i = 0; i <= a.length; i++) { + tmp[i] = [i]; + } + for (let j = 0; j <= b.length; j++) { + tmp[0][j] = j; + } + for (let i = 1; i <= a.length; i++) { + for (let j = 1; j <= b.length; j++) { + tmp[i][j] = Math.min( + tmp[i - 1][j] + 1, // deletion + tmp[i][j - 1] + 1, // insertion + tmp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1), // substitution + ); + } + } + return tmp[a.length][b.length]; +} + +function getMatchScore( + record: PublicSupplyRecord, + query: string, + exactMatchId: string | null, +): number { + if (exactMatchId && record.id === exactMatchId) { + return 100; + } + + const queryWords = query.toLowerCase().split(/\s+/).filter(Boolean); + if (queryWords.length === 0) return 0; + + const nameEsWords = record.nameEs.toLowerCase().split(/\s+/).filter(Boolean); + const nameEnWords = (record.nameEn ?? '') + .toLowerCase() + .split(/\s+/) + .filter(Boolean); + const aliasWords = record.aliases.flatMap((a) => + a.toLowerCase().split(/\s+/).filter(Boolean), + ); + + let score = 0; + + for (const qWord of queryWords) { + let bestWordScore = 0; + + const check = (tWord: string, weight: number) => { + if (tWord === qWord) { + bestWordScore = Math.max(bestWordScore, 10 * weight); + } else if (tWord.startsWith(qWord)) { + bestWordScore = Math.max(bestWordScore, 8 * weight); + } else if (tWord.includes(qWord)) { + bestWordScore = Math.max(bestWordScore, 5 * weight); + } else if (qWord.length >= 3) { + const dist = levenshtein(qWord, tWord); + const maxAllowed = qWord.length > 5 ? 2 : 1; + if (dist <= maxAllowed) { + bestWordScore = Math.max(bestWordScore, (5 - dist) * weight); + } + } + }; + + for (const tWord of nameEsWords) check(tWord, 2); + for (const tWord of nameEnWords) check(tWord, 2); + for (const tWord of aliasWords) check(tWord, 1.5); + + if (bestWordScore === 0) { + // If a query word matches absolutely nothing, the whole query fails + return 0; + } + score += bestWordScore; + } + + return score; +} + /** * Índice de resolución exacta (nombre canónico es/en, código y alias). Los * registros ya son `active`, así que los campos de gestión del agregado se @@ -55,56 +131,60 @@ export class ListSupplies { async execute(query: SupplyCatalogQuery): Promise { const records = await this.catalog.listActive(); const resolvedLocale = query.locale === 'en' ? 'en' : 'es'; - // El resolver (índice de match exacto por nombre/código/alias) solo hace - // falta cuando hay término de búsqueda; evitamos construirlo —O(N) objetos— - // en listados por categoría o sin filtro. const normalizedQuery = query.q ? normalizeSupplyText(query.q) : ''; const exactMatchId = query.q ? toSupplyResolver(records).resolve(query.q) : null; - const filtered = records.filter((record) => { - if (query.categorySlug && record.categorySlug !== query.categorySlug) { - return false; - } - if (!normalizedQuery) { - return true; - } - if (exactMatchId && record.id === exactMatchId) { - return true; - } - const searchable = normalizeSupplyText( - [ - record.code, - record.nameEs, - record.nameEn ?? '', - record.categorySlug, - record.categoryLabelEs, - record.categoryLabelEn ?? '', - ...record.aliases, - ].join(' '), + if (!normalizedQuery) { + const filtered = records.filter((record) => { + return ( + !query.categorySlug || record.categorySlug === query.categorySlug + ); + }); + const collator = new Intl.Collator(resolvedLocale, { + sensitivity: 'base', + }); + const sorted = [...filtered].sort((a, b) => { + const aLabel = + resolvedLocale === 'en' && a.nameEn ? a.nameEn : a.nameEs; + const bLabel = + resolvedLocale === 'en' && b.nameEn ? b.nameEn : b.nameEs; + return collator.compare(aLabel, bLabel); + }); + return sorted.slice(query.offset, query.offset + query.limit); + } + + const scored = records + .map((record) => ({ + record, + score: getMatchScore(record, normalizedQuery, exactMatchId), + })) + .filter( + (item) => + item.score > 0 && + (!query.categorySlug || + item.record.categorySlug === query.categorySlug), ); - return searchable.includes(normalizedQuery); - }); const collator = new Intl.Collator(resolvedLocale, { sensitivity: 'base' }); - const sorted = [...filtered].sort((a, b) => { - if (exactMatchId) { - const aExact = a.id === exactMatchId; - const bExact = b.id === exactMatchId; - if (aExact !== bExact) { - return aExact ? -1 : 1; - } - } - const aLabel = resolvedLocale === 'en' && a.nameEn ? a.nameEn : a.nameEs; - const bLabel = resolvedLocale === 'en' && b.nameEn ? b.nameEn : b.nameEs; - const labelCompare = collator.compare(aLabel, bLabel); - if (labelCompare !== 0) { - return labelCompare; + const sorted = scored.sort((a, b) => { + if (b.score !== a.score) { + return b.score - a.score; } - return a.code.localeCompare(b.code, 'en'); + const aLabel = + resolvedLocale === 'en' && a.record.nameEn + ? a.record.nameEn + : a.record.nameEs; + const bLabel = + resolvedLocale === 'en' && b.record.nameEn + ? b.record.nameEn + : b.record.nameEs; + return collator.compare(aLabel, bLabel); }); - return sorted.slice(query.offset, query.offset + query.limit); + return sorted + .map((item) => item.record) + .slice(query.offset, query.offset + query.limit); } } 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..571970be 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 = uuid('supply_id')) { 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..028ef0c0 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(''); 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: supplyId === '' ? null : supplyId }} + onChange={(patch) => { + if (patch.name !== undefined) setDescription(patch.name); + if (patch.supplyId !== undefined) setSupplyId(patch.supplyId ?? ''); + }} /> + +
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..87fb3582 --- /dev/null +++ b/apps/web/src/components/molecules/supply-selector.tsx @@ -0,0 +1,288 @@ +'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; + +const CheckIcon = ( + + + +); + +const SearchIcon = ( + + + +); + +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 [focusedIndex, setFocusedIndex] = useState(-1); + const debounceRef = useRef | null>(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); + setFocusedIndex(-1); + return; + } + + 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()}`); + 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); + setFocusedIndex(-1); + } catch { + if (seq !== requestSeq.current) return; + setResults([]); + setError(true); + setIsOpen(true); + setFocusedIndex(-1); + } 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); + }; + }, []); + + const chooseSupply = useCallback((supply: SupplyOption) => { + onChange({ name: supply.name, supplyId: supply.id }); + setIsOpen(false); + setResults([]); + setFocusedIndex(-1); + }, [onChange]); + + function handleInputChange(next: string) { + if (debounceRef.current !== null) clearTimeout(debounceRef.current); + setResults([]); + setError(false); + setLoading(false); + setFocusedIndex(-1); + onChange({ name: next, supplyId: null }); + setIsOpen(next.trim().length >= 2); + debounceRef.current = setTimeout(() => { + void fetchResults(next); + }, DEBOUNCE_MS); + } + + function handleOther() { + onChange({ supplyId: null }); + setIsOpen(false); + setFocusedIndex(-1); + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (!isOpen) { + if (e.key === 'ArrowDown' && value.name.trim().length >= 2) { + setIsOpen(true); + void fetchResults(value.name); + } + return; + } + + if (e.key === 'ArrowDown') { + e.preventDefault(); + setFocusedIndex((prev) => (prev + 1 < results.length ? prev + 1 : prev)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setFocusedIndex((prev) => (prev - 1 >= 0 ? prev - 1 : prev)); + } else if (e.key === 'Enter') { + if (focusedIndex >= 0 && focusedIndex < results.length) { + e.preventDefault(); + chooseSupply(results[focusedIndex]); + } + } else if (e.key === 'Escape') { + e.preventDefault(); + setIsOpen(false); + } + } + + return ( +
+
+ handleInputChange(e.target.value)} + onKeyDown={handleKeyDown} + onFocus={() => setIsOpen(value.name.trim().length >= 2)} + placeholder={placeholder} + role="combobox" + aria-autocomplete="list" + aria-expanded={isOpen} + aria-haspopup="listbox" + aria-controls={`${id}-listbox`} + icon={value.supplyId ? CheckIcon : SearchIcon} + className={`flex-1 ${ + value.supplyId + ? 'border-emerald-500 bg-emerald-50/10 focus:border-emerald-500 focus:ring-emerald-500/30' + : '' + }`} + /> + +
+ +

{copy.hint}

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

{copy.loading}

+ ) : error ? ( +

{copy.error}

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

{copy.empty}

+ ) : ( +
    + {results.map((supply, idx) => ( +
  • + +
  • + ))} +
+ )} +
+ )} +
+ ); +} 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 */