Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions apps/api/drizzle/0027_transport_capacities.sql
Original file line number Diff line number Diff line change
@@ -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);
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export class CapacityNotFoundError extends Error {
constructor(id: string) {
super(`Transport capacity not found: ${id}`);
this.name = 'CapacityNotFoundError';
}
}
48 changes: 48 additions & 0 deletions apps/api/src/contexts/logistics/application/capacity-view.ts
Original file line number Diff line number Diff line change
@@ -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(),
};
}
Original file line number Diff line number Diff line change
@@ -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);
});
});
32 changes: 32 additions & 0 deletions apps/api/src/contexts/logistics/application/list-capacities.ts
Original file line number Diff line number Diff line change
@@ -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<CapacityView[]> {
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);
}
}
Original file line number Diff line number Diff line change
@@ -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<string | null> {
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);
});
});
63 changes: 63 additions & 0 deletions apps/api/src/contexts/logistics/application/publish-capacity.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
}
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
}
}
Loading