Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,4 @@ apps/api/uploads/

# Local git worktrees for parallel agent work — never commit
.worktrees/
.claude/worktrees/
11 changes: 9 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Multi-emergency **material aid coordination + logistics** platform (org: **Globa
- **`apps/api`** — NestJS 11, **hexagonal / DDD** (ports & adapters, CQRS-light, domain events via Redis/BullMQ), Drizzle ORM, Postgres 16, Redis 7. Swagger at `/docs`.
- **`apps/web`** — Next 16 (App Router, React 19), **Atomic Design**, Tailwind 4, Leaflet + `leaflet.markercluster`. Consumes the typed client. (See `apps/web/AGENTS.md` for Next-16 specifics.)
- **`packages/api-client`** — `@reliefhub/api-client`, openapi-fetch typed client. Regenerate with `pnpm gen:api`.
- **`packages/warehouse-*`** — `@globalemergency/warehouse-core` (reusable, framework-free WMS domain: `kernel`/`catalog`/`inventory`/`containers`/`logistics` over an opaque `ScopeId`) + `@globalemergency/warehouse-postgres` (Drizzle persistence). Consumed by `apps/api` via `workspace:*`; **pure domain** — ESLint forbids `@nestjs/*`/`drizzle-orm`/`pg`/`apps/*` imports. Own tests run via `node --test` on compiled JS (`pnpm --filter '@globalemergency/warehouse-core' test`). **The extraction-to-OSS-product roadmap is [EPIC #355](https://github.com/GlobalEmergency/ResponseGrid/issues/355)** — read it before touching these packages.
- **`packages/warehouse-*`** — `@globalemergency/warehouse-core` (reusable, framework-free WMS domain: `kernel`/`catalog`/`inventory`/`containers`/`logistics` over an opaque `ScopeId`) + `@globalemergency/warehouse-postgres` (Drizzle persistence). Consumed by `apps/api` via `workspace:*`; **pure domain** — ESLint forbids `@nestjs/*`/`drizzle-orm`/`pg`/`apps/*` imports. Own tests run via `node --test` on compiled JS (`pnpm --filter '@globalemergency/warehouse-core' test`). **The extraction-to-OSS-product roadmap is [EPIC #355](https://github.com/GlobalEmergency/ResponseGrid/issues/355)** — read it before touching these packages. `kernel`/`catalog`/`containers` are the actual domain model behind the `supplies` bounded context (below) — but `apps/api/src/contexts/supplies/infrastructure/drizzle` writes its **own** Drizzle adapters implementing those ports against ResponseGrid's production schema; it does **not** use `@globalemergency/warehouse-postgres`, which today only persists the separate `inventory`/`wms` module (vehicle-fleet work).
- TDD throughout. Dev infra via docker-compose.

## Architecture
Expand All @@ -25,7 +25,14 @@ Hexagonal bounded contexts in `apps/api/src/contexts/` (18):
`emergencies · resources · needs · offers · supplies · logistics · volunteers · reports · identity` (authz: grants/service-accounts/API keys) `· groups` (cuadrillas) `· organizations · accreditation · templates · notifications · audit · metrics · geocoding · files`.

- `domain/` and `application/` must **NOT** import `@nestjs/*`, drizzle, or infrastructure — enforced by ESLint `no-restricted-imports`. Output ports are mocked in tests; the real domain runs.
- **`supplies` (insumos) — upstream supporting domain (the material line is the core of the platform).** Owns the single material-line model reused everywhere instead of a copy per context: `Category` (the **canonical** enum of aid-material categories — food/water/hygiene/clothing/medical/shelter/tools/other + health vertical medicines/medical_equipment/medical_supplies/medical_personnel), `CategoryDefinition` (the `categories` table: localized labels, hierarchy, import aliases, facet counts — enrichment, not a parallel enum), and the **`SupplyLine`** value object (`name/quantity/unit/category/presentation`). `needs`, `offers`, `resources` (inventory) and `logistics` (`ShipmentItem`) depend on it. Public **`GET /categories`** surfaces the taxonomy. (Replaced the old `taxonomy` context; designed to later reference a `Supply` master-data catalogue and a grouping aggregate —palet/caja/lote— without touching consumers (OCP/DIP).)
- **`supplies` (insumos) — upstream supporting domain (the material line is the core of the platform).** `apps/api/src/contexts/supplies` is a hexagonal **host** (application use cases + Drizzle persistence + HTTP controllers) around domain types that actually live in `@globalemergency/warehouse-core`'s `kernel`/`catalog`/`containers` modules (see `packages/warehouse-*` above). It replaced the old `taxonomy` context and now owns considerably more than a shared taxonomy:
- **Category**: the taxonomy moved from a closed enum to a **data-driven, validated slug** (`CategorySlug`, format `^[a-z][a-z0-9_]*$`) — the `Category` TS enum survives only as the seed of **core** slugs (food/water/hygiene/clothing/medical/shelter/tools/other + health vertical + UCAB subcategories), exported as `CORE_CATEGORY_SLUGS`. `CategoryDefinition` (the `categories` table) enriches any slug — core or tenant-defined — with localized labels, hierarchy (`parentSlug`), `kind` (material/personnel), facet counts and `externalCodes`. Core slugs are **protected**: archiving one throws `CategoryProtectedError` → 409. Public `GET /categories`; admin CRUD at `admin/categories` (`catalogue:manage`).
- **`Supply` master-data catalogue** (epic #228, #222): the previously-planned catalogue is now real. Each `Supply` has a server-assigned code `XXX-NNNN` (prefix from its category's root, sequential), a canonical `name` (**base = `es`**) plus **i18n translations** (`supply_translations`, one row per locale) resolved with **fallback to the base name** when the requested locale has none, a `categorySlug`, freeform `attributes` (jsonb, governed by the attribute metamodel below), an optional **`variantOfId`** (a variant just points at its parent — no separate variants table; `attributes` is what actually differentiates it), **aliases** (`supply_aliases`, synonyms resolved by `SupplyResolver` against canonical name + every translation + code + alias; a label matching more than one supply resolves to `null` — ambiguous, must be `merge`d rather than aliased), `status` (`active`/`archived`; `merge` folds a duplicate into a canonical target, moving its aliases and re-pointing its variants), **`nature`** (`fungible`/`reusable`/`human` — logistics classification, lives on the *supply*, not the category, since one category can mix natures; `null` = unclassified), and **`externalCodes`** (open `namespace → code` map for interop, e.g. `unspsc`/`hxl`, indexed with a GIN index for reverse lookup). `unitWeightKg`/`unitVolumeM3` exist in the domain (for vehicle load calculations) but **ResponseGrid's HTTP and persistence don't wire them yet** — always `null` here.
- **Attribute metamodel** (epic #228): `AttributeDefinition` — an admin-defined, typed field (`text`/`number`/`integer`/`boolean`/`enum`/`date`/`quantity`, with optional `required`/`options`/`unit`) anchored to a `categorySlug` **family** and inherited down the category tree: `resolveEffectiveSchema` unions a category's own definitions with all its ancestors' (root → leaf); the same `key` appearing at two levels of that chain is a collision error, not an override. `Supply.attributes` is validated/coerced against this effective schema on create/edit (`validateAttributes`); a family with no definitions stays a free, ungoverned jsonb (backwards-compatible). CRUD at `admin/attribute-definitions`.
- **Tenancy** (base global + per-tenant extension): every governed row (`Supply`, `supply_aliases`, `attribute_definitions`) carries a nullable `scopeId` — `null` = global (shared by every tenant), a tenant id = an **additive** extension (effective schema/catalogue = global ∪ that tenant's rows, never overriding; a tenant `key`/alias/code colliding with a global one is rejected, not shadowed). **ResponseGrid's admin HTTP endpoints only operate in the global scope today** — `scopeId` isn't yet an exposed request parameter, so tenancy is a domain/persistence capability ahead of the wired API.
- **Governance**: `admin/supplies`, `admin/categories` and `admin/attribute-definitions` all require the `catalogue:manage` permission, granted today only via the `platform_admin` role (`ALL_PERMISSIONS`) — i.e. admins-only, no per-emergency coordinator access. The public `GET /supplies`, `GET /supplies/:id` and `GET /categories` serve **only active rows** and never expose `status`/`registrationNotes`/`nature`/`externalCodes`/`scopeId` (those stay in the admin-only projections/DTOs).
- **Containers** (palet/caja/lote) are **implemented**, not just designed-for (`#140`): a `Container` groups `SupplyLine[]` with declared weight/volume, nests under a parent (self-FK, cycle-checked), seals (lines become immutable once sealed) and moves between a polymorphic holder (`resource`|`shipment`, no FK). Routes under `supplies/containers/*`, gated on being a coordinator of the container's emergency (not `catalogue:manage` — containers are per-emergency, the catalogue is global).
- `needs`, `offers`, `resources` (inventory) and `logistics` (`ShipmentItem`) still depend on the shared **`SupplyLine`** value object (`name/quantity/unit/category/presentation/expiresAt`), which now carries an optional **`supplyId`** soft link to the master-data `Supply` (nullable — legacy free-text lines aren't forced to link); unlinked legacy text is reconciled via `admin/supplies/backfill` (report + best-effort execute, idempotent).
- **Resource inventory.** A resource/place declares the material it holds for delivery as `SupplyLine[]` (`resource_items`, FK-cascade; migration `0028`). Captured at `/registrar`. The public detail endpoint exposes it **aggregated to distinct categories** (`inventoryCategories`) for privacy; the full lines are persisted for coordination.
- Shared kernel in `apps/api/src/shared/` (EmergencyId, Location, Priority, DomainEvent, cross-context errors, the single `pg.Pool` via `DatabaseModule`). The material-line/category model lives in `supplies` (an upstream context), not in the shared kernel.
- Authorization model: `Principal → Grant(role@scope) → Permission → can()`; `@RequirePermission` decorator (replaced the legacy per-context coordinator guards). API keys: `X-API-Key: rh_live_…`.
Expand Down
69 changes: 69 additions & 0 deletions apps/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -6569,6 +6569,9 @@
}
}
}
},
"404": {
"description": "Supply not found (or archived)"
}
},
"summary": "Get a supply by id",
Expand Down Expand Up @@ -6602,8 +6605,17 @@
}
}
},
"400": {
"description": "Payload inválido (código de insumo, atributos sin casar el esquema efectivo de la categoría, etc.)"
},
"401": {
"description": "Token ausente o inválido"
},
"403": {
"description": "Falta el permiso catalogue:manage"
},
"404": {
"description": "categorySlug o variantOfId no existen"
}
},
"security": [
Expand Down Expand Up @@ -6664,6 +6676,9 @@
}
}
},
"401": {
"description": "Token ausente o inválido"
},
"403": {
"description": "Falta el permiso catalogue:manage"
}
Expand Down Expand Up @@ -6694,6 +6709,9 @@
}
}
},
"401": {
"description": "Token ausente o inválido"
},
"403": {
"description": "Falta el permiso catalogue:manage"
}
Expand Down Expand Up @@ -6722,6 +6740,9 @@
}
}
},
"401": {
"description": "Token ausente o inválido"
},
"403": {
"description": "Falta el permiso catalogue:manage"
}
Expand Down Expand Up @@ -6761,8 +6782,14 @@
}
}
},
"401": {
"description": "Token ausente o inválido"
},
"403": {
"description": "Falta el permiso catalogue:manage"
},
"404": {
"description": "Insumo no encontrado"
}
},
"security": [
Expand Down Expand Up @@ -6801,8 +6828,17 @@
"204": {
"description": ""
},
"400": {
"description": "Payload inválido o atributos que no casan el esquema"
},
"401": {
"description": "Token ausente o inválido"
},
"403": {
"description": "Falta el permiso catalogue:manage"
},
"404": {
"description": "Insumo, categorySlug o variantOfId no encontrados"
}
},
"security": [
Expand Down Expand Up @@ -6833,8 +6869,14 @@
"204": {
"description": ""
},
"401": {
"description": "Token ausente o inválido"
},
"403": {
"description": "Falta el permiso catalogue:manage"
},
"404": {
"description": "Insumo no encontrado"
}
},
"security": [
Expand Down Expand Up @@ -6865,8 +6907,14 @@
"204": {
"description": ""
},
"401": {
"description": "Token ausente o inválido"
},
"403": {
"description": "Falta el permiso catalogue:manage"
},
"404": {
"description": "Insumo no encontrado"
}
},
"security": [
Expand Down Expand Up @@ -6907,8 +6955,17 @@
"204": {
"description": ""
},
"401": {
"description": "Token ausente o inválido"
},
"403": {
"description": "Falta el permiso catalogue:manage"
},
"404": {
"description": "Insumo no encontrado"
},
"409": {
"description": "El alias ya apunta a otro insumo del mismo scope"
}
},
"security": [
Expand Down Expand Up @@ -6947,6 +7004,9 @@
"204": {
"description": ""
},
"401": {
"description": "Token ausente o inválido"
},
"403": {
"description": "Falta el permiso catalogue:manage"
}
Expand Down Expand Up @@ -6980,8 +7040,17 @@
"204": {
"description": ""
},
"400": {
"description": "sourceId y targetId son el mismo insumo"
},
"401": {
"description": "Token ausente o inválido"
},
"403": {
"description": "Falta el permiso catalogue:manage"
},
"404": {
"description": "sourceId o targetId no encontrados"
}
},
"security": [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,17 @@ import {
UseGuards,
} from '@nestjs/common';
import {
ApiBadRequestResponse,
ApiBearerAuth,
ApiConflictResponse,
ApiCreatedResponse,
ApiForbiddenResponse,
ApiNoContentResponse,
ApiNotFoundResponse,
ApiOkResponse,
ApiOperation,
ApiTags,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import { CreateSupply } from '../../application/create-supply';
import { EditSupply } from '../../application/edit-supply';
Expand Down Expand Up @@ -66,6 +70,7 @@ import {
*/
@ApiTags('supplies-admin')
@ApiBearerAuth()
@ApiUnauthorizedResponse({ description: 'Token ausente o inválido' })
@ApiForbiddenResponse({ description: 'Falta el permiso catalogue:manage' })
@UseGuards(JwtAuthGuard, PermissionGuard)
@UseFilters(SuppliesDomainExceptionFilter)
Expand All @@ -90,6 +95,13 @@ export class SuppliesAdminController {
@HttpCode(201)
@ApiOperation({ summary: 'Crear un insumo (asigna código INS-NNNN)' })
@ApiCreatedResponse({ type: CreateSupplyResponseDto })
@ApiBadRequestResponse({
description:
'Payload inválido (código de insumo, atributos sin casar el esquema efectivo de la categoría, etc.)',
})
@ApiNotFoundResponse({
description: 'categorySlug o variantOfId no existen',
})
async create(@Body() dto: CreateSupplyDto): Promise<CreateSupplyResponseDto> {
const result = await this.createSupply.execute({
name: dto.name,
Expand Down Expand Up @@ -145,6 +157,7 @@ export class SuppliesAdminController {
@Get(':id')
@ApiOperation({ summary: 'Detalle de gestión de un insumo' })
@ApiOkResponse({ type: AdminSupplyDto })
@ApiNotFoundResponse({ description: 'Insumo no encontrado' })
async get(@Param('id', ParseUUIDPipe) id: string): Promise<AdminSupplyView> {
return this.getSupplyAdmin.execute(id);
}
Expand All @@ -153,6 +166,12 @@ export class SuppliesAdminController {
@HttpCode(204)
@ApiOperation({ summary: 'Editar un insumo (code no editable)' })
@ApiNoContentResponse()
@ApiBadRequestResponse({
description: 'Payload inválido o atributos que no casan el esquema',
})
@ApiNotFoundResponse({
description: 'Insumo, categorySlug o variantOfId no encontrados',
})
async edit(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: EditSupplyDto,
Expand All @@ -165,6 +184,7 @@ export class SuppliesAdminController {
@HttpCode(204)
@ApiOperation({ summary: 'Archivar un insumo' })
@ApiNoContentResponse()
@ApiNotFoundResponse({ description: 'Insumo no encontrado' })
async archive(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
await this.archiveSupply.execute(id);
this.cache.invalidate();
Expand All @@ -174,6 +194,7 @@ export class SuppliesAdminController {
@HttpCode(204)
@ApiOperation({ summary: 'Reactivar un insumo archivado' })
@ApiNoContentResponse()
@ApiNotFoundResponse({ description: 'Insumo no encontrado' })
async restore(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
await this.restoreSupply.execute(id);
this.cache.invalidate();
Expand All @@ -183,6 +204,10 @@ export class SuppliesAdminController {
@HttpCode(204)
@ApiOperation({ summary: 'Añadir un alias/sinónimo a un insumo' })
@ApiNoContentResponse()
@ApiNotFoundResponse({ description: 'Insumo no encontrado' })
@ApiConflictResponse({
description: 'El alias ya apunta a otro insumo del mismo scope',
})
async addAlias(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: AddSupplyAliasDto,
Expand All @@ -207,6 +232,10 @@ export class SuppliesAdminController {
@HttpCode(204)
@ApiOperation({ summary: 'Fusionar un insumo duplicado en el canónico' })
@ApiNoContentResponse()
@ApiBadRequestResponse({
description: 'sourceId y targetId son el mismo insumo',
})
@ApiNotFoundResponse({ description: 'sourceId o targetId no encontrados' })
async merge(@Body() dto: MergeSuppliesDto): Promise<void> {
await this.mergeSupplies.execute({
sourceId: dto.sourceId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from '@nestjs/common';
import {
ApiHeader,
ApiNotFoundResponse,
ApiOkResponse,
ApiOperation,
ApiQuery,
Expand Down Expand Up @@ -91,6 +92,7 @@ export class SuppliesController {
description: 'Preferred locale',
})
@ApiOkResponse({ description: 'Supply detail', type: SupplyDto })
@ApiNotFoundResponse({ description: 'Supply not found (or archived)' })
async get(
@Param('id') id: string,
@Query('locale') localeParam: string | undefined,
Expand Down
Loading
Loading