Skip to content
14 changes: 14 additions & 0 deletions apps/api/drizzle/0045_supply_line_supply_id.sql
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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
Expand All @@ -69,16 +70,20 @@ export const SHIPMENT_EVENT_QUEUE = Symbol('ShipmentEventQueue');

interface EventQueue {
queue: Queue;
connection: IORedis;
connection: IORedisConnection;
}

const eventQueueProvider = {
provide: SHIPMENT_EVENT_QUEUE,
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 };
},
};
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/contexts/needs/application/create-need.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
}),
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -31,6 +32,7 @@ function makeItems() {
quantity: 50,
unit: 'liters',
category: Category.Water,
supplyId: '1e4b5f3b-5c9c-4f77-8f50-3d2dbdc0c7d8',
}),
];
}
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand Down Expand Up @@ -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',
}),
),
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})),
Expand Down
13 changes: 9 additions & 4 deletions apps/api/src/contexts/needs/infrastructure/needs.module.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -45,21 +45,26 @@ 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 = {
provide: EVENT_QUEUE,
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 };
},
};
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/contexts/offers/application/edit-offer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface EditOfferItemCommand {
quantity: number;
unit: string | null;
category: Category;
supplyId?: string | null;
presentation: string | null;
}

Expand Down Expand Up @@ -59,6 +60,7 @@ export class EditOffer {
quantity: i.quantity,
unit: i.unit,
category: i.category,
supplyId: i.supplyId ?? null,
presentation: i.presentation,
}),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface DonationIntakeLineView {
quantity: number;
unit: string | null;
category: Category;
supplyId: string | null;
presentation: string | null;
expiresAt: string | null;
}
Expand Down Expand Up @@ -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,
})),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ describe('GetDonationIntakeTracking', () => {
quantity: 5,
unit: 'l',
category: Category.Water,
supplyId: null,
presentation: null,
},
]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface DonationIntakeTrackingLine {
quantity: number;
unit: string | null;
category: Category;
supplyId: string | null;
presentation: string | null;
}

Expand Down Expand Up @@ -63,6 +64,7 @@ export class GetDonationIntakeTracking {
quantity: line.quantity,
unit: line.unit,
category: line.category,
supplyId: line.supplyId ?? null,
presentation: line.presentation ?? null,
})),
};
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/contexts/offers/application/submit-offer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export interface SubmitOfferItemCommand {
quantity: number;
unit: string | null;
category: Category;
supplyId?: string | null;
presentation: string | null;
}

Expand Down Expand Up @@ -99,6 +100,7 @@ export class SubmitOffer {
quantity: i.quantity,
unit: i.unit,
category: i.category,
supplyId: i.supplyId ?? null,
presentation: i.presentation,
}),
);
Expand Down
13 changes: 1 addition & 12 deletions apps/api/src/contexts/offers/domain/intake-line.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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(),
Expand All @@ -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),
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -39,6 +40,7 @@ function makeIntake(code: string) {
unit: 'sacos',
presentation: null,
expiresAt: '2026-07-01',
supplyId: '1e4b5f3b-5c9c-4f77-8f50-3d2dbdc0c7d8',
},
},
],
Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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(),
Expand All @@ -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 () => {
Expand All @@ -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();
Expand Down
Loading
Loading