diff --git a/apps/api/drizzle/0027_transport_capacities.sql b/apps/api/drizzle/0027_transport_capacities.sql new file mode 100644 index 00000000..4d50ed28 --- /dev/null +++ b/apps/api/drizzle/0027_transport_capacities.sql @@ -0,0 +1,26 @@ +-- Capacidad de transporte (EPIC #103 · #105). Oferta de un servicio logístico +-- (no de material): un proveedor (voluntario u organización: transportista / +-- naviera / aerolínea) ofrece mover carga de un punto a otro, con modo, +-- capacidad (peso/volumen), corredor (origen → destino opcional) o área, +-- ventana temporal y restricciones. Distinto del agregado material `offers`. +CREATE TABLE IF NOT EXISTS transport_capacities ( + id uuid PRIMARY KEY, + emergency_id uuid NOT NULL, + provider_type text NOT NULL, + provider_id uuid NOT NULL, + mode text NOT NULL, + weight_kg double precision, + volume_m3 double precision, + origin_municipality text NOT NULL, + destination_municipality text, + available_from timestamptz NOT NULL, + available_until timestamptz, + refrigerated boolean NOT NULL DEFAULT false, + notes text, + status text NOT NULL, + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL +); + +CREATE INDEX IF NOT EXISTS transport_capacities_emergency_idx + ON transport_capacities (emergency_id); diff --git a/apps/api/src/contexts/logistics/application/capacity-not-found.error.ts b/apps/api/src/contexts/logistics/application/capacity-not-found.error.ts new file mode 100644 index 00000000..4673bca3 --- /dev/null +++ b/apps/api/src/contexts/logistics/application/capacity-not-found.error.ts @@ -0,0 +1,6 @@ +export class CapacityNotFoundError extends Error { + constructor(id: string) { + super(`Transport capacity not found: ${id}`); + this.name = 'CapacityNotFoundError'; + } +} diff --git a/apps/api/src/contexts/logistics/application/capacity-view.ts b/apps/api/src/contexts/logistics/application/capacity-view.ts new file mode 100644 index 00000000..c0022b7b --- /dev/null +++ b/apps/api/src/contexts/logistics/application/capacity-view.ts @@ -0,0 +1,48 @@ +import { TransportCapacity } from '../domain/transport-capacity'; +import { + TransportMode, + ProviderType, + CapacityStatus, +} from '../domain/transport-capacity-enums'; + +/** Serializable read model for a transport capacity (dates as ISO strings). */ +export interface CapacityView { + id: string; + emergencyId: string; + providerType: ProviderType; + providerId: string; + mode: TransportMode; + weightKg: number | null; + volumeM3: number | null; + originMunicipality: string; + destinationMunicipality: string | null; + availableFrom: string; + availableUntil: string | null; + refrigerated: boolean; + notes: string | null; + status: CapacityStatus; + createdAt: string; + updatedAt: string; +} + +export function toCapacityView(c: TransportCapacity): CapacityView { + return { + id: c.id.value, + emergencyId: c.emergencyId.value, + providerType: c.providerType, + providerId: c.providerId, + mode: c.mode, + weightKg: c.weightKg, + volumeM3: c.volumeM3, + originMunicipality: c.originMunicipality, + destinationMunicipality: c.destinationMunicipality, + availableFrom: c.availableFrom.toISOString(), + availableUntil: + c.availableUntil === null ? null : c.availableUntil.toISOString(), + refrigerated: c.refrigerated, + notes: c.notes, + status: c.status, + createdAt: c.createdAt.toISOString(), + updatedAt: c.updatedAt.toISOString(), + }; +} diff --git a/apps/api/src/contexts/logistics/application/list-capacities.spec.ts b/apps/api/src/contexts/logistics/application/list-capacities.spec.ts new file mode 100644 index 00000000..eb699292 --- /dev/null +++ b/apps/api/src/contexts/logistics/application/list-capacities.spec.ts @@ -0,0 +1,67 @@ +import { ListCapacities } from './list-capacities'; +import { InMemoryTransportCapacityRepository } from '../infrastructure/in-memory-transport-capacity.repository'; +import { TransportCapacity } from '../domain/transport-capacity'; +import { TransportCapacityId } from '../domain/transport-capacity-id'; +import { EmergencyId } from '../../../shared/domain/emergency-id'; +import { + TransportMode, + ProviderType, + CapacityStatus, +} from '../domain/transport-capacity-enums'; + +const EM = '44444444-4444-4444-8444-444444444444'; + +function make(mode: TransportMode): TransportCapacity { + return TransportCapacity.create({ + id: TransportCapacityId.create(), + emergencyId: EmergencyId.fromString(EM), + providerType: ProviderType.Volunteer, + providerId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + mode, + weightKg: 100, + volumeM3: null, + originMunicipality: 'Valencia', + destinationMunicipality: null, + availableFrom: new Date('2026-07-01T00:00:00.000Z'), + availableUntil: null, + refrigerated: false, + notes: null, + }); +} + +describe('ListCapacities', () => { + it('lists capacities for an emergency as serializable views, filterable by mode', async () => { + const repo = new InMemoryTransportCapacityRepository(); + await repo.save(make(TransportMode.Road)); + await repo.save(make(TransportMode.Air)); + const useCase = new ListCapacities(repo); + + const all = await useCase.execute({ emergencyId: EM }); + expect(all).toHaveLength(2); + expect(typeof all[0].availableFrom).toBe('string'); + + const road = await useCase.execute({ + emergencyId: EM, + mode: TransportMode.Road, + }); + expect(road).toHaveLength(1); + expect(road[0].mode).toBe(TransportMode.Road); + }); + + it('filters by status', async () => { + const repo = new InMemoryTransportCapacityRepository(); + const road = make(TransportMode.Road); + const air = make(TransportMode.Air); + air.withdraw(); + await repo.save(road); + await repo.save(air); + const useCase = new ListCapacities(repo); + + const available = await useCase.execute({ + emergencyId: EM, + status: CapacityStatus.Available, + }); + expect(available).toHaveLength(1); + expect(available[0].status).toBe(CapacityStatus.Available); + }); +}); diff --git a/apps/api/src/contexts/logistics/application/list-capacities.ts b/apps/api/src/contexts/logistics/application/list-capacities.ts new file mode 100644 index 00000000..91453a1b --- /dev/null +++ b/apps/api/src/contexts/logistics/application/list-capacities.ts @@ -0,0 +1,32 @@ +import { + TransportCapacityRepository, + TransportCapacityFilters, +} from '../domain/ports/transport-capacity.repository'; +import { EmergencyId } from '../../../shared/domain/emergency-id'; +import { + TransportMode, + CapacityStatus, +} from '../domain/transport-capacity-enums'; +import { CapacityView, toCapacityView } from './capacity-view'; + +export interface ListCapacitiesQuery { + emergencyId: string; + mode?: TransportMode; + status?: CapacityStatus; +} + +export class ListCapacities { + constructor(private readonly repo: TransportCapacityRepository) {} + + async execute(query: ListCapacitiesQuery): Promise { + const filters: TransportCapacityFilters = {}; + if (query.mode !== undefined) filters.mode = query.mode; + if (query.status !== undefined) filters.status = query.status; + + const capacities = await this.repo.findByEmergency( + EmergencyId.fromString(query.emergencyId), + filters, + ); + return capacities.map(toCapacityView); + } +} diff --git a/apps/api/src/contexts/logistics/application/publish-capacity.spec.ts b/apps/api/src/contexts/logistics/application/publish-capacity.spec.ts new file mode 100644 index 00000000..566d3f87 --- /dev/null +++ b/apps/api/src/contexts/logistics/application/publish-capacity.spec.ts @@ -0,0 +1,63 @@ +import { PublishCapacity, PublishCapacityCommand } from './publish-capacity'; +import { InMemoryTransportCapacityRepository } from '../infrastructure/in-memory-transport-capacity.repository'; +import { EmergencyStatusReader } from '../domain/ports/emergency-status-reader'; +import { + TransportMode, + ProviderType, + CapacityStatus, +} from '../domain/transport-capacity-enums'; +import { TransportCapacityId } from '../domain/transport-capacity-id'; +import { EmergencyId } from '../../../shared/domain/emergency-id'; + +const EM = '44444444-4444-4444-8444-444444444444'; + +class FakeStatusReader implements EmergencyStatusReader { + constructor(private readonly status: string | null) {} + getStatus(): Promise { + return Promise.resolve(this.status); + } +} + +function baseCmd(): PublishCapacityCommand { + return { + emergencyId: EM, + providerType: ProviderType.Volunteer, + providerId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + mode: TransportMode.Road, + weightKg: 800, + volumeM3: null, + originMunicipality: 'Valencia', + destinationMunicipality: null, + availableFrom: new Date('2026-07-01T00:00:00.000Z'), + availableUntil: null, + refrigerated: false, + notes: null, + }; +} + +describe('PublishCapacity', () => { + it('publishes a capacity for an active emergency and persists it as Available', async () => { + const repo = new InMemoryTransportCapacityRepository(); + const useCase = new PublishCapacity(repo, new FakeStatusReader('active')); + + const { id } = await useCase.execute(baseCmd()); + + const saved = await repo.findById(TransportCapacityId.fromString(id)); + expect(saved).not.toBeNull(); + expect(saved!.status).toBe(CapacityStatus.Available); + expect(saved!.mode).toBe(TransportMode.Road); + + const all = await repo.findByEmergency(EmergencyId.fromString(EM)); + expect(all).toHaveLength(1); + }); + + it('rejects publishing when the emergency is not active', async () => { + const repo = new InMemoryTransportCapacityRepository(); + const useCase = new PublishCapacity(repo, new FakeStatusReader('closed')); + + await expect(useCase.execute(baseCmd())).rejects.toThrow(); + + const all = await repo.findByEmergency(EmergencyId.fromString(EM)); + expect(all).toHaveLength(0); + }); +}); diff --git a/apps/api/src/contexts/logistics/application/publish-capacity.ts b/apps/api/src/contexts/logistics/application/publish-capacity.ts new file mode 100644 index 00000000..982eef6d --- /dev/null +++ b/apps/api/src/contexts/logistics/application/publish-capacity.ts @@ -0,0 +1,63 @@ +import { TransportCapacityRepository } from '../domain/ports/transport-capacity.repository'; +import { EmergencyStatusReader } from '../domain/ports/emergency-status-reader'; +import { TransportCapacity } from '../domain/transport-capacity'; +import { TransportCapacityId } from '../domain/transport-capacity-id'; +import { EmergencyId } from '../../../shared/domain/emergency-id'; +import { + TransportMode, + ProviderType, +} from '../domain/transport-capacity-enums'; +import { EmergencyNotAcceptingIntakeError } from '../../emergencies/domain/emergency-not-accepting-intake.error'; + +const ACTIVE_STATUS = 'active'; + +export interface PublishCapacityCommand { + emergencyId: string; + providerType: ProviderType; + providerId: string; + mode: TransportMode; + weightKg: number | null; + volumeM3: number | null; + originMunicipality: string; + destinationMunicipality: string | null; + availableFrom: Date; + availableUntil: Date | null; + refrigerated: boolean; + notes: string | null; +} + +export class PublishCapacity { + constructor( + private readonly repo: TransportCapacityRepository, + private readonly emergencyStatusReader: EmergencyStatusReader, + ) {} + + async execute(cmd: PublishCapacityCommand): Promise<{ id: string }> { + const status = await this.emergencyStatusReader.getStatus(cmd.emergencyId); + if (status !== ACTIVE_STATUS) { + throw new EmergencyNotAcceptingIntakeError( + cmd.emergencyId, + status ?? 'not-found', + ); + } + + const capacity = TransportCapacity.create({ + id: TransportCapacityId.create(), + emergencyId: EmergencyId.fromString(cmd.emergencyId), + providerType: cmd.providerType, + providerId: cmd.providerId, + mode: cmd.mode, + weightKg: cmd.weightKg, + volumeM3: cmd.volumeM3, + originMunicipality: cmd.originMunicipality, + destinationMunicipality: cmd.destinationMunicipality, + availableFrom: cmd.availableFrom, + availableUntil: cmd.availableUntil, + refrigerated: cmd.refrigerated, + notes: cmd.notes, + }); + + await this.repo.save(capacity); + return { id: capacity.id.value }; + } +} diff --git a/apps/api/src/contexts/logistics/application/withdraw-capacity.spec.ts b/apps/api/src/contexts/logistics/application/withdraw-capacity.spec.ts new file mode 100644 index 00000000..6290183d --- /dev/null +++ b/apps/api/src/contexts/logistics/application/withdraw-capacity.spec.ts @@ -0,0 +1,54 @@ +import { WithdrawCapacity } from './withdraw-capacity'; +import { CapacityNotFoundError } from './capacity-not-found.error'; +import { InMemoryTransportCapacityRepository } from '../infrastructure/in-memory-transport-capacity.repository'; +import { TransportCapacity } from '../domain/transport-capacity'; +import { TransportCapacityId } from '../domain/transport-capacity-id'; +import { EmergencyId } from '../../../shared/domain/emergency-id'; +import { + TransportMode, + ProviderType, + CapacityStatus, +} from '../domain/transport-capacity-enums'; + +const EM = '44444444-4444-4444-8444-444444444444'; + +function make(): TransportCapacity { + return TransportCapacity.create({ + id: TransportCapacityId.create(), + emergencyId: EmergencyId.fromString(EM), + providerType: ProviderType.Organization, + providerId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + mode: TransportMode.Sea, + weightKg: 20000, + volumeM3: null, + originMunicipality: 'La Guaira', + destinationMunicipality: 'Valencia', + availableFrom: new Date('2026-07-01T00:00:00.000Z'), + availableUntil: null, + refrigerated: false, + notes: null, + }); +} + +describe('WithdrawCapacity', () => { + it('withdraws an existing capacity', async () => { + const repo = new InMemoryTransportCapacityRepository(); + const cap = make(); + await repo.save(cap); + const useCase = new WithdrawCapacity(repo); + + await useCase.execute({ capacityId: cap.id.value }); + + const found = await repo.findById(cap.id); + expect(found!.status).toBe(CapacityStatus.Withdrawn); + }); + + it('throws when the capacity does not exist', async () => { + const repo = new InMemoryTransportCapacityRepository(); + const useCase = new WithdrawCapacity(repo); + + await expect( + useCase.execute({ capacityId: '11111111-1111-4111-8111-111111111111' }), + ).rejects.toThrow(CapacityNotFoundError); + }); +}); diff --git a/apps/api/src/contexts/logistics/application/withdraw-capacity.ts b/apps/api/src/contexts/logistics/application/withdraw-capacity.ts new file mode 100644 index 00000000..a712172d --- /dev/null +++ b/apps/api/src/contexts/logistics/application/withdraw-capacity.ts @@ -0,0 +1,22 @@ +import { TransportCapacityRepository } from '../domain/ports/transport-capacity.repository'; +import { TransportCapacityId } from '../domain/transport-capacity-id'; +import { CapacityNotFoundError } from './capacity-not-found.error'; + +export interface WithdrawCapacityCommand { + capacityId: string; +} + +export class WithdrawCapacity { + constructor(private readonly repo: TransportCapacityRepository) {} + + async execute(cmd: WithdrawCapacityCommand): Promise { + const capacity = await this.repo.findById( + TransportCapacityId.fromString(cmd.capacityId), + ); + if (capacity === null) { + throw new CapacityNotFoundError(cmd.capacityId); + } + capacity.withdraw(); + await this.repo.save(capacity); + } +} diff --git a/apps/api/src/contexts/logistics/domain/ports/emergency-status-reader.ts b/apps/api/src/contexts/logistics/domain/ports/emergency-status-reader.ts new file mode 100644 index 00000000..9f4679eb --- /dev/null +++ b/apps/api/src/contexts/logistics/domain/ports/emergency-status-reader.ts @@ -0,0 +1,14 @@ +/** + * Port — logistics context. + * + * Re-declares the same interface as other contexts' emergency status reader so + * this context stays independent. The Drizzle adapter is shared in infrastructure. + */ +export const LOGISTICS_EMERGENCY_STATUS_READER = Symbol( + 'LogisticsEmergencyStatusReader', +); + +export interface EmergencyStatusReader { + /** Returns the current status string, or null when the emergency does not exist. */ + getStatus(emergencyId: string): Promise; +} diff --git a/apps/api/src/contexts/logistics/domain/ports/transport-capacity.repository.ts b/apps/api/src/contexts/logistics/domain/ports/transport-capacity.repository.ts new file mode 100644 index 00000000..d8b0e1c8 --- /dev/null +++ b/apps/api/src/contexts/logistics/domain/ports/transport-capacity.repository.ts @@ -0,0 +1,22 @@ +import { TransportCapacity } from '../transport-capacity'; +import { TransportCapacityId } from '../transport-capacity-id'; +import { EmergencyId } from '../../../../shared/domain/emergency-id'; +import { TransportMode, CapacityStatus } from '../transport-capacity-enums'; + +export const TRANSPORT_CAPACITY_REPOSITORY = Symbol( + 'TransportCapacityRepository', +); + +export interface TransportCapacityFilters { + mode?: TransportMode; + status?: CapacityStatus; +} + +export interface TransportCapacityRepository { + save(capacity: TransportCapacity): Promise; + findById(id: TransportCapacityId): Promise; + findByEmergency( + emergencyId: EmergencyId, + filters?: TransportCapacityFilters, + ): Promise; +} diff --git a/apps/api/src/contexts/logistics/domain/transport-capacity-enums.ts b/apps/api/src/contexts/logistics/domain/transport-capacity-enums.ts new file mode 100644 index 00000000..e7f7115f --- /dev/null +++ b/apps/api/src/contexts/logistics/domain/transport-capacity-enums.ts @@ -0,0 +1,21 @@ +/** Mode of transport offered. The citizen with a moto/car/van/truck is `road`. */ +export enum TransportMode { + Road = 'road', + Sea = 'sea', + Air = 'air', +} + +/** Who offers the capacity — a polymorphic principal (cf. EPIC #103). */ +export enum ProviderType { + Volunteer = 'volunteer', + Organization = 'organization', +} + +export enum CapacityStatus { + /** Open and offerable. */ + Available = 'available', + /** Held for a shipment (cf. #106). */ + Reserved = 'reserved', + /** The provider has retired the offer. */ + Withdrawn = 'withdrawn', +} diff --git a/apps/api/src/contexts/logistics/domain/transport-capacity-errors.ts b/apps/api/src/contexts/logistics/domain/transport-capacity-errors.ts new file mode 100644 index 00000000..ebeb9dfc --- /dev/null +++ b/apps/api/src/contexts/logistics/domain/transport-capacity-errors.ts @@ -0,0 +1,29 @@ +import { CapacityStatus } from './transport-capacity-enums'; + +export class InvalidTransportCapacityError extends Error { + constructor(msg: string) { + super(msg); + this.name = 'InvalidTransportCapacityError'; + } +} + +export class CapacityNotAvailableError extends Error { + constructor() { + super('Transport capacity is not available'); + this.name = 'CapacityNotAvailableError'; + } +} + +export class CapacityNotReservedError extends Error { + constructor() { + super('Transport capacity is not reserved'); + this.name = 'CapacityNotReservedError'; + } +} + +export class CapacityCannotBeWithdrawnError extends Error { + constructor(status: CapacityStatus) { + super(`Transport capacity in status '${status}' cannot be withdrawn`); + this.name = 'CapacityCannotBeWithdrawnError'; + } +} diff --git a/apps/api/src/contexts/logistics/domain/transport-capacity-id.ts b/apps/api/src/contexts/logistics/domain/transport-capacity-id.ts new file mode 100644 index 00000000..91fae339 --- /dev/null +++ b/apps/api/src/contexts/logistics/domain/transport-capacity-id.ts @@ -0,0 +1,21 @@ +import { randomUUID } from 'node:crypto'; + +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +export class TransportCapacityId { + private constructor(public readonly value: string) {} + + static create(): TransportCapacityId { + return new TransportCapacityId(randomUUID()); + } + + static fromString(s: string): TransportCapacityId { + if (!UUID_RE.test(s)) throw new Error(`Invalid TransportCapacityId: ${s}`); + return new TransportCapacityId(s); + } + + equals(o: TransportCapacityId): boolean { + return this.value === o.value; + } +} diff --git a/apps/api/src/contexts/logistics/domain/transport-capacity.spec.ts b/apps/api/src/contexts/logistics/domain/transport-capacity.spec.ts new file mode 100644 index 00000000..5b214b69 --- /dev/null +++ b/apps/api/src/contexts/logistics/domain/transport-capacity.spec.ts @@ -0,0 +1,115 @@ +import { TransportCapacity } from './transport-capacity'; +import { TransportCapacityId } from './transport-capacity-id'; +import { EmergencyId } from '../../../shared/domain/emergency-id'; +import { + TransportMode, + ProviderType, + CapacityStatus, +} from './transport-capacity-enums'; +import { + InvalidTransportCapacityError, + CapacityNotAvailableError, + CapacityNotReservedError, + CapacityCannotBeWithdrawnError, +} from './transport-capacity-errors'; + +const EM = '44444444-4444-4444-8444-444444444444'; +const PROVIDER = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + +function make( + o: { + weightKg?: number | null; + volumeM3?: number | null; + originMunicipality?: string; + destinationMunicipality?: string | null; + availableFrom?: Date; + availableUntil?: Date | null; + refrigerated?: boolean; + } = {}, +): TransportCapacity { + return TransportCapacity.create({ + id: TransportCapacityId.create(), + emergencyId: EmergencyId.fromString(EM), + providerType: ProviderType.Volunteer, + providerId: PROVIDER, + mode: TransportMode.Road, + weightKg: o.weightKg === undefined ? 500 : o.weightKg, + volumeM3: o.volumeM3 === undefined ? null : o.volumeM3, + originMunicipality: o.originMunicipality ?? 'Valencia', + destinationMunicipality: + o.destinationMunicipality === undefined + ? null + : o.destinationMunicipality, + availableFrom: o.availableFrom ?? new Date('2026-07-01T00:00:00.000Z'), + availableUntil: o.availableUntil === undefined ? null : o.availableUntil, + refrigerated: o.refrigerated ?? false, + notes: null, + }); +} + +describe('TransportCapacity', () => { + it('is created Available and trims the origin municipality', () => { + const c = make({ originMunicipality: ' Valencia ' }); + expect(c.status).toBe(CapacityStatus.Available); + expect(c.originMunicipality).toBe('Valencia'); + }); + + it('requires a positive weight or volume', () => { + expect(() => make({ weightKg: null, volumeM3: null })).toThrow( + InvalidTransportCapacityError, + ); + expect(() => make({ weightKg: 0, volumeM3: null })).toThrow( + InvalidTransportCapacityError, + ); + expect(() => make({ weightKg: null, volumeM3: 2 })).not.toThrow(); + }); + + it('rejects an empty origin municipality', () => { + expect(() => make({ originMunicipality: ' ' })).toThrow( + InvalidTransportCapacityError, + ); + }); + + it('rejects availableUntil before availableFrom', () => { + expect(() => + make({ + availableFrom: new Date('2026-07-02T00:00:00.000Z'), + availableUntil: new Date('2026-07-01T00:00:00.000Z'), + }), + ).toThrow(InvalidTransportCapacityError); + }); + + it('reserve(): Available → Reserved, and cannot reserve twice', () => { + const c = make(); + c.reserve(); + expect(c.status).toBe(CapacityStatus.Reserved); + expect(() => c.reserve()).toThrow(CapacityNotAvailableError); + }); + + it('release(): Reserved → Available; releasing an Available throws', () => { + const c = make(); + expect(() => c.release()).toThrow(CapacityNotReservedError); + c.reserve(); + c.release(); + expect(c.status).toBe(CapacityStatus.Available); + }); + + it('withdraw(): allowed once, not twice', () => { + const c = make(); + c.withdraw(); + expect(c.status).toBe(CapacityStatus.Withdrawn); + expect(() => c.withdraw()).toThrow(CapacityCannotBeWithdrawnError); + }); + + it('round-trips through a snapshot', () => { + const c = make({ + destinationMunicipality: 'Caracas', + volumeM3: 12, + refrigerated: true, + }); + const restored = TransportCapacity.fromSnapshot(c.toSnapshot()); + expect(restored.toSnapshot()).toEqual(c.toSnapshot()); + expect(restored.destinationMunicipality).toBe('Caracas'); + expect(restored.refrigerated).toBe(true); + }); +}); diff --git a/apps/api/src/contexts/logistics/domain/transport-capacity.ts b/apps/api/src/contexts/logistics/domain/transport-capacity.ts new file mode 100644 index 00000000..667a76dc --- /dev/null +++ b/apps/api/src/contexts/logistics/domain/transport-capacity.ts @@ -0,0 +1,208 @@ +import { TransportCapacityId } from './transport-capacity-id'; +import { EmergencyId } from '../../../shared/domain/emergency-id'; +import { + TransportMode, + ProviderType, + CapacityStatus, +} from './transport-capacity-enums'; +import { + InvalidTransportCapacityError, + CapacityNotAvailableError, + CapacityNotReservedError, + CapacityCannotBeWithdrawnError, +} from './transport-capacity-errors'; + +/** + * Aggregate root — an offer of *transport service* (not material): a provider + * (a volunteer with a vehicle, or an organization such as a carrier/airline/ + * shipping line) offers to move cargo from one place to another, with a mode, + * a capacity (weight and/or volume), a corridor (origin → optional destination) + * or area, a time window and constraints. Distinct from the material + * `DonationOffer` of the `offers` context. EPIC #103 · #105. + */ +export interface CreateTransportCapacityProps { + id: TransportCapacityId; + emergencyId: EmergencyId; + providerType: ProviderType; + providerId: string; + mode: TransportMode; + weightKg: number | null; + volumeM3: number | null; + originMunicipality: string; + destinationMunicipality: string | null; + availableFrom: Date; + availableUntil: Date | null; + refrigerated: boolean; + notes: string | null; +} + +export interface TransportCapacitySnapshot { + id: string; + emergencyId: string; + providerType: ProviderType; + providerId: string; + mode: TransportMode; + weightKg: number | null; + volumeM3: number | null; + originMunicipality: string; + destinationMunicipality: string | null; + availableFrom: Date; + availableUntil: Date | null; + refrigerated: boolean; + notes: string | null; + status: CapacityStatus; + createdAt: Date; + updatedAt: Date; +} + +export class TransportCapacity { + private constructor( + public readonly id: TransportCapacityId, + public readonly emergencyId: EmergencyId, + public readonly providerType: ProviderType, + public readonly providerId: string, + public readonly mode: TransportMode, + public readonly weightKg: number | null, + public readonly volumeM3: number | null, + public readonly originMunicipality: string, + public readonly destinationMunicipality: string | null, + public readonly availableFrom: Date, + public readonly availableUntil: Date | null, + public readonly refrigerated: boolean, + public readonly notes: string | null, + private _status: CapacityStatus, + public readonly createdAt: Date, + private _updatedAt: Date, + ) {} + + static create(props: CreateTransportCapacityProps): TransportCapacity { + const hasWeight = props.weightKg !== null && props.weightKg > 0; + const hasVolume = props.volumeM3 !== null && props.volumeM3 > 0; + if (!hasWeight && !hasVolume) { + throw new InvalidTransportCapacityError( + 'A transport capacity must declare a positive weight (kg) or volume (m³)', + ); + } + if (props.weightKg !== null && props.weightKg <= 0) { + throw new InvalidTransportCapacityError( + 'weightKg must be greater than 0 when set', + ); + } + if (props.volumeM3 !== null && props.volumeM3 <= 0) { + throw new InvalidTransportCapacityError( + 'volumeM3 must be greater than 0 when set', + ); + } + if (props.originMunicipality.trim().length === 0) { + throw new InvalidTransportCapacityError( + 'originMunicipality must not be empty', + ); + } + if ( + props.availableUntil !== null && + props.availableUntil.getTime() < props.availableFrom.getTime() + ) { + throw new InvalidTransportCapacityError( + 'availableUntil must not be before availableFrom', + ); + } + const now = new Date(); + return new TransportCapacity( + props.id, + props.emergencyId, + props.providerType, + props.providerId, + props.mode, + props.weightKg, + props.volumeM3, + props.originMunicipality.trim(), + props.destinationMunicipality === null + ? null + : props.destinationMunicipality.trim(), + props.availableFrom, + props.availableUntil, + props.refrigerated, + props.notes, + CapacityStatus.Available, + now, + now, + ); + } + + static fromSnapshot(s: TransportCapacitySnapshot): TransportCapacity { + return new TransportCapacity( + TransportCapacityId.fromString(s.id), + EmergencyId.fromString(s.emergencyId), + s.providerType, + s.providerId, + s.mode, + s.weightKg, + s.volumeM3, + s.originMunicipality, + s.destinationMunicipality, + s.availableFrom, + s.availableUntil, + s.refrigerated, + s.notes, + s.status, + s.createdAt, + s.updatedAt, + ); + } + + get status(): CapacityStatus { + return this._status; + } + + get updatedAt(): Date { + return this._updatedAt; + } + + /** Reserve this capacity for a shipment (cf. #106). Must be Available. */ + reserve(): void { + if (this._status !== CapacityStatus.Available) { + throw new CapacityNotAvailableError(); + } + this._status = CapacityStatus.Reserved; + this._updatedAt = new Date(); + } + + /** Release a reserved capacity back to Available. Must be Reserved. */ + release(): void { + if (this._status !== CapacityStatus.Reserved) { + throw new CapacityNotReservedError(); + } + this._status = CapacityStatus.Available; + this._updatedAt = new Date(); + } + + /** Withdraw the offer (provider no longer offers it). Not when withdrawn. */ + withdraw(): void { + if (this._status === CapacityStatus.Withdrawn) { + throw new CapacityCannotBeWithdrawnError(this._status); + } + this._status = CapacityStatus.Withdrawn; + this._updatedAt = new Date(); + } + + toSnapshot(): TransportCapacitySnapshot { + return { + id: this.id.value, + emergencyId: this.emergencyId.value, + providerType: this.providerType, + providerId: this.providerId, + mode: this.mode, + weightKg: this.weightKg, + volumeM3: this.volumeM3, + originMunicipality: this.originMunicipality, + destinationMunicipality: this.destinationMunicipality, + availableFrom: this.availableFrom, + availableUntil: this.availableUntil, + refrigerated: this.refrigerated, + notes: this.notes, + status: this._status, + createdAt: this.createdAt, + updatedAt: this._updatedAt, + }; + } +} diff --git a/apps/api/src/contexts/logistics/infrastructure/drizzle/drizzle-transport-capacity.repository.int-spec.ts b/apps/api/src/contexts/logistics/infrastructure/drizzle/drizzle-transport-capacity.repository.int-spec.ts new file mode 100644 index 00000000..45a445dc --- /dev/null +++ b/apps/api/src/contexts/logistics/infrastructure/drizzle/drizzle-transport-capacity.repository.int-spec.ts @@ -0,0 +1,106 @@ +import { createDb, Db } from '../../../../shared/db'; +import { transportCapacitiesTable } from './schema'; +import { DrizzleTransportCapacityRepository } from './drizzle-transport-capacity.repository'; +import { TransportCapacity } from '../../domain/transport-capacity'; +import { TransportCapacityId } from '../../domain/transport-capacity-id'; +import { EmergencyId } from '../../../../shared/domain/emergency-id'; +import { + TransportMode, + ProviderType, + CapacityStatus, +} from '../../domain/transport-capacity-enums'; +import type { Pool } from 'pg'; + +const URL = + process.env.DATABASE_URL ?? + 'postgres://reliefhub:reliefhub@localhost:5433/reliefhub'; +const EM = '44444444-4444-4444-8444-444444444444'; +const PROVIDER_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + +function makeCapacity(overrides?: { mode?: TransportMode }) { + return TransportCapacity.create({ + id: TransportCapacityId.create(), + emergencyId: EmergencyId.fromString(EM), + providerType: ProviderType.Organization, + providerId: PROVIDER_ID, + mode: overrides?.mode ?? TransportMode.Road, + weightKg: 1000, + volumeM3: null, + originMunicipality: 'Caracas', + destinationMunicipality: 'Valencia', + availableFrom: new Date('2026-07-01T08:00:00.000Z'), + availableUntil: null, + refrigerated: false, + notes: null, + }); +} + +describe('DrizzleTransportCapacityRepository (integration)', () => { + let db: Db; + let pool: Pool; + let repo: DrizzleTransportCapacityRepository; + + beforeAll(() => { + ({ db, pool } = createDb(URL)); + repo = new DrizzleTransportCapacityRepository(db); + }); + + afterAll(async () => { + await pool.end(); + }); + + beforeEach(async () => { + await db.delete(transportCapacitiesTable); + }); + + it('round-trips a capacity through Postgres', async () => { + const cap = makeCapacity(); + await repo.save(cap); + const found = await repo.findById(cap.id); + + expect(found).not.toBeNull(); + expect(found!.id.value).toBe(cap.id.value); + expect(found!.status).toBe(CapacityStatus.Available); + expect(found!.providerType).toBe(ProviderType.Organization); + expect(found!.providerId).toBe(PROVIDER_ID); + expect(found!.mode).toBe(TransportMode.Road); + expect(found!.weightKg).toBe(1000); + expect(found!.volumeM3).toBeNull(); + expect(found!.originMunicipality).toBe('Caracas'); + expect(found!.destinationMunicipality).toBe('Valencia'); + expect(found!.refrigerated).toBe(false); + expect(found!.notes).toBeNull(); + }); + + it('save() updates status on upsert (withdraw)', async () => { + const cap = makeCapacity(); + await repo.save(cap); + cap.withdraw(); + await repo.save(cap); + const found = await repo.findById(cap.id); + expect(found!.status).toBe(CapacityStatus.Withdrawn); + }); + + it('findByEmergency filters by mode and status', async () => { + const road = makeCapacity({ mode: TransportMode.Road }); + const air = makeCapacity({ mode: TransportMode.Air }); + air.withdraw(); + await repo.save(road); + await repo.save(air); + + const byMode = await repo.findByEmergency(EmergencyId.fromString(EM), { + mode: TransportMode.Road, + }); + expect(byMode).toHaveLength(1); + expect(byMode[0].mode).toBe(TransportMode.Road); + + const available = await repo.findByEmergency(EmergencyId.fromString(EM), { + status: CapacityStatus.Available, + }); + expect(available).toHaveLength(1); + expect(available[0].mode).toBe(TransportMode.Road); + + const all = await repo.findByEmergency(EmergencyId.fromString(EM)); + expect(all).toHaveLength(2); + }); +}); diff --git a/apps/api/src/contexts/logistics/infrastructure/drizzle/drizzle-transport-capacity.repository.ts b/apps/api/src/contexts/logistics/infrastructure/drizzle/drizzle-transport-capacity.repository.ts new file mode 100644 index 00000000..8fdee549 --- /dev/null +++ b/apps/api/src/contexts/logistics/infrastructure/drizzle/drizzle-transport-capacity.repository.ts @@ -0,0 +1,106 @@ +import { and, eq, SQL } from 'drizzle-orm'; +import { Db } from '../../../../shared/db'; +import { transportCapacitiesTable } from './schema'; +import { + TransportCapacityRepository, + TransportCapacityFilters, +} from '../../domain/ports/transport-capacity.repository'; +import { + TransportCapacity, + TransportCapacitySnapshot, +} from '../../domain/transport-capacity'; +import { TransportCapacityId } from '../../domain/transport-capacity-id'; +import { EmergencyId } from '../../../../shared/domain/emergency-id'; +import { + TransportMode, + ProviderType, + CapacityStatus, +} from '../../domain/transport-capacity-enums'; + +type Row = typeof transportCapacitiesTable.$inferSelect; + +function rowToSnapshot(row: Row): TransportCapacitySnapshot { + return { + id: row.id, + emergencyId: row.emergencyId, + providerType: row.providerType as ProviderType, + providerId: row.providerId, + mode: row.mode as TransportMode, + weightKg: row.weightKg ?? null, + volumeM3: row.volumeM3 ?? null, + originMunicipality: row.originMunicipality, + destinationMunicipality: row.destinationMunicipality ?? null, + availableFrom: row.availableFrom, + availableUntil: row.availableUntil ?? null, + refrigerated: row.refrigerated, + notes: row.notes ?? null, + status: row.status as CapacityStatus, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +export class DrizzleTransportCapacityRepository implements TransportCapacityRepository { + constructor(private readonly db: Db) {} + + async save(capacity: TransportCapacity): Promise { + const s = capacity.toSnapshot(); + await this.db + .insert(transportCapacitiesTable) + .values({ + id: s.id, + emergencyId: s.emergencyId, + providerType: s.providerType, + providerId: s.providerId, + mode: s.mode, + weightKg: s.weightKg, + volumeM3: s.volumeM3, + originMunicipality: s.originMunicipality, + destinationMunicipality: s.destinationMunicipality, + availableFrom: s.availableFrom, + availableUntil: s.availableUntil, + refrigerated: s.refrigerated, + notes: s.notes, + status: s.status, + createdAt: s.createdAt, + updatedAt: s.updatedAt, + }) + .onConflictDoUpdate({ + target: transportCapacitiesTable.id, + set: { + status: s.status, + updatedAt: s.updatedAt, + }, + }); + } + + async findById(id: TransportCapacityId): Promise { + const rows = await this.db + .select() + .from(transportCapacitiesTable) + .where(eq(transportCapacitiesTable.id, id.value)) + .limit(1); + if (!rows[0]) return null; + return TransportCapacity.fromSnapshot(rowToSnapshot(rows[0])); + } + + async findByEmergency( + emergencyId: EmergencyId, + filters?: TransportCapacityFilters, + ): Promise { + const conditions: SQL[] = [ + eq(transportCapacitiesTable.emergencyId, emergencyId.value), + ]; + if (filters?.mode !== undefined) { + conditions.push(eq(transportCapacitiesTable.mode, filters.mode)); + } + if (filters?.status !== undefined) { + conditions.push(eq(transportCapacitiesTable.status, filters.status)); + } + const rows = await this.db + .select() + .from(transportCapacitiesTable) + .where(and(...conditions)); + return rows.map((r) => TransportCapacity.fromSnapshot(rowToSnapshot(r))); + } +} diff --git a/apps/api/src/contexts/logistics/infrastructure/drizzle/schema.ts b/apps/api/src/contexts/logistics/infrastructure/drizzle/schema.ts new file mode 100644 index 00000000..fa436eef --- /dev/null +++ b/apps/api/src/contexts/logistics/infrastructure/drizzle/schema.ts @@ -0,0 +1,27 @@ +import { + pgTable, + uuid, + text, + timestamp, + doublePrecision, + boolean, +} from 'drizzle-orm/pg-core'; + +export const transportCapacitiesTable = pgTable('transport_capacities', { + id: uuid('id').primaryKey(), + emergencyId: uuid('emergency_id').notNull(), + providerType: text('provider_type').notNull(), + providerId: uuid('provider_id').notNull(), + mode: text('mode').notNull(), + weightKg: doublePrecision('weight_kg'), + volumeM3: doublePrecision('volume_m3'), + originMunicipality: text('origin_municipality').notNull(), + destinationMunicipality: text('destination_municipality'), + availableFrom: timestamp('available_from', { withTimezone: true }).notNull(), + availableUntil: timestamp('available_until', { withTimezone: true }), + refrigerated: boolean('refrigerated').notNull(), + notes: text('notes'), + status: text('status').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull(), +}); diff --git a/apps/api/src/contexts/logistics/infrastructure/in-memory-transport-capacity.repository.ts b/apps/api/src/contexts/logistics/infrastructure/in-memory-transport-capacity.repository.ts new file mode 100644 index 00000000..eaa36f4b --- /dev/null +++ b/apps/api/src/contexts/logistics/infrastructure/in-memory-transport-capacity.repository.ts @@ -0,0 +1,38 @@ +import { + TransportCapacityRepository, + TransportCapacityFilters, +} from '../domain/ports/transport-capacity.repository'; +import { + TransportCapacity, + TransportCapacitySnapshot, +} from '../domain/transport-capacity'; +import { TransportCapacityId } from '../domain/transport-capacity-id'; +import { EmergencyId } from '../../../shared/domain/emergency-id'; + +export class InMemoryTransportCapacityRepository implements TransportCapacityRepository { + private store = new Map(); + + save(capacity: TransportCapacity): Promise { + this.store.set(capacity.id.value, capacity.toSnapshot()); + return Promise.resolve(); + } + + findById(id: TransportCapacityId): Promise { + const snap = this.store.get(id.value); + return Promise.resolve(snap ? TransportCapacity.fromSnapshot(snap) : null); + } + + findByEmergency( + emergencyId: EmergencyId, + filters?: TransportCapacityFilters, + ): Promise { + const result = [...this.store.values()] + .filter((s) => s.emergencyId === emergencyId.value) + .filter((s) => filters?.mode === undefined || s.mode === filters.mode) + .filter( + (s) => filters?.status === undefined || s.status === filters.status, + ) + .map((s) => TransportCapacity.fromSnapshot(s)); + return Promise.resolve(result); + } +}