Scaffold Fastify + Drizzle API with pick-by-light
Layered: routes -> services -> repositories -> db, over PostgreSQL 18 with split roles (findr_migrator for DDL, findr_app for DML). Keycloak JWT auth with per-endpoint scope guards. Drizzle migrations run with the DDL role at container start. Pick-by-light: one ESP32 controller drives one WS2812 chain through several boxes; each box owns a contiguous LED slice. MQTT contract in src/lib/mqtt-topics.ts / docs/mqtt.md. /v1/pick controls light/idle/off and /v1/boxes + /v1/controllers manage the strip mapping. Reference resource /v1/parts wired end to end. Vitest, Biome, multi-stage Dockerfile, local compose.yml with Postgres. Signed-off-by: Peter Siegmund <mars3142@noreply.mars3142.dev>
This commit is contained in:
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
coverage
|
||||||
|
*.md
|
||||||
|
compose.yml
|
||||||
|
.github
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# ── HTTP ────────────────────────────────────────────────────────────────────
|
||||||
|
NODE_ENV=development
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=3000
|
||||||
|
LOG_LEVEL=info
|
||||||
|
# Comma-separated list of allowed browser origins for CORS (findr-web).
|
||||||
|
CORS_ORIGIN=http://localhost:5173
|
||||||
|
|
||||||
|
# ── Postgres ────────────────────────────────────────────────────────────────
|
||||||
|
# Superuser — only used by docker compose to bootstrap the cluster.
|
||||||
|
POSTGRES_SUPERUSER_PASSWORD=postgres
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
|
||||||
|
# DDL role: owns schema "findr", used ONLY to run migrations at deploy time.
|
||||||
|
DATABASE_DDL_URL=postgres://findr_migrator:findr_migrator@localhost:5432/findr
|
||||||
|
FINDR_MIGRATOR_PASSWORD=findr_migrator
|
||||||
|
|
||||||
|
# DML role: runtime queries. No schema-changing privileges.
|
||||||
|
DATABASE_URL=postgres://findr_app:findr_app@localhost:5432/findr
|
||||||
|
FINDR_APP_PASSWORD=findr_app
|
||||||
|
|
||||||
|
# Run pending migrations (with the DDL role) on startup. Required in deployments.
|
||||||
|
RUN_MIGRATIONS_ON_START=true
|
||||||
|
|
||||||
|
# ── Keycloak (realm "mars3142", lives on mars3142-02) ───────────────────────
|
||||||
|
KEYCLOAK_ISSUER=https://auth.mars3142.dev/realms/mars3142
|
||||||
|
# The "findr-audience" client scope maps this into the token's aud.
|
||||||
|
KEYCLOAK_AUDIENCE=findr-api
|
||||||
|
# Permission scopes the API enforces (Keycloak client scopes on findr-web):
|
||||||
|
# findr:parts:read findr:parts:write findr:stock:write findr:light:control
|
||||||
|
|
||||||
|
# ── MQTT (Mosquitto broker on mars3142-01, same host as findr) ──────────────
|
||||||
|
# In the deployment the API reaches Mosquitto by its compose service name over
|
||||||
|
# the shared network — plain, port 1883, no TLS (faster).
|
||||||
|
# mqtts://mqtt.mars3142.dev:8883 is the public endpoint (browsers / devices).
|
||||||
|
MQTT_URL=mqtt://mosquitto:1883
|
||||||
|
# Broker user `findr-api`. The ESP32 firmware uses `findr-controller`.
|
||||||
|
MQTT_USERNAME=findr-api
|
||||||
|
MQTT_PASSWORD=
|
||||||
|
# Base topic. Contract in src/lib/mqtt-topics.ts / docs/mqtt.md:
|
||||||
|
# findr/box/<n>/{config,cmd/light,state/light,evt/button}
|
||||||
|
# findr/controller/<esp>/{state/online,state/info,cmd/identify}
|
||||||
|
MQTT_TOPIC_PREFIX=findr
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
coverage/
|
||||||
|
.DS_Store
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
FROM node:22-slim AS base
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# ── install all deps (incl. dev) for the build ──────────────────────────────
|
||||||
|
FROM base AS deps
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN --mount=type=cache,target=/root/.npm npm ci
|
||||||
|
|
||||||
|
# ── compile TS → dist/ ─────────────────────────────────────────────────────
|
||||||
|
FROM deps AS build
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ── prod-only deps ─────────────────────────────────────────────────────────
|
||||||
|
FROM base AS prod-deps
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
|
||||||
|
|
||||||
|
# ── runtime ────────────────────────────────────────────────────────────────
|
||||||
|
FROM base AS runtime
|
||||||
|
COPY --from=prod-deps /app/node_modules ./node_modules
|
||||||
|
COPY --from=build /app/dist ./dist
|
||||||
|
COPY --from=build /app/drizzle ./drizzle
|
||||||
|
COPY package.json ./
|
||||||
|
|
||||||
|
USER node
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Container runs migrations (DDL role) then serves — see src/index.ts.
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# findr-api
|
||||||
|
|
||||||
|
HTTP API for **findr** — a parts inventory with pick-by-light. Fastify + Drizzle
|
||||||
|
over PostgreSQL, Keycloak for auth, MQTT to drive the LED boxes.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
config.ts env parsing (validated once at boot)
|
||||||
|
app.ts builds the Fastify instance
|
||||||
|
index.ts entrypoint: migrate → build → listen
|
||||||
|
plugins/
|
||||||
|
db.ts Drizzle client bound to the DML role (findr_app)
|
||||||
|
auth.ts Keycloak JWT verification + scope guards
|
||||||
|
mqtt.ts thin MQTT transport (publish / subscribe / routing)
|
||||||
|
pick-by-light.ts box + controller registry, light/idle/off, box config
|
||||||
|
db/
|
||||||
|
schema.ts Drizzle schema (Postgres schema "findr")
|
||||||
|
client.ts connection factory (used by runtime + migrator)
|
||||||
|
run-migrations.ts migrate with the DDL role (findr_migrator)
|
||||||
|
migrate.ts CLI wrapper for `npm run migrate`
|
||||||
|
repositories/ data access — Drizzle queries only
|
||||||
|
services/ business rules — framework-free, throw domain errors
|
||||||
|
routes/ HTTP layer — validation, scopes, wiring
|
||||||
|
lib/
|
||||||
|
errors.ts domain error types → HTTP status codes
|
||||||
|
scopes.ts Keycloak scope constants
|
||||||
|
mqtt-topics.ts pick-by-light topic + payload contract
|
||||||
|
led-map.ts compartment → LED index (grid + override)
|
||||||
|
docs/mqtt.md MQTT contract + broker users/ACL
|
||||||
|
drizzle/ generated SQL migrations (committed)
|
||||||
|
db/init/ Postgres first-boot script: roles + schema
|
||||||
|
```
|
||||||
|
|
||||||
|
Request flow: `route → service → repository → db`.
|
||||||
|
|
||||||
|
## Two database roles
|
||||||
|
|
||||||
|
| Role | Privileges | Used by |
|
||||||
|
| ---------------- | --------------------------------- | ------------------------------- |
|
||||||
|
| `findr_migrator` | DDL — owns schema `findr` | migrations only (deploy + CLI) |
|
||||||
|
| `findr_app` | DML — SELECT/INSERT/UPDATE/DELETE | the running API |
|
||||||
|
|
||||||
|
`db/init/01-roles.sh` creates both on first cluster init and wires
|
||||||
|
`ALTER DEFAULT PRIVILEGES` so any table the migrator creates is immediately
|
||||||
|
usable (data only) by the app role. The migration bookkeeping lives in a
|
||||||
|
separate `drizzle` schema.
|
||||||
|
|
||||||
|
**Every deployment runs migrations before serving.** `src/index.ts` calls
|
||||||
|
`runMigrations()` (DDL role) when `RUN_MIGRATIONS_ON_START=true`, then starts the
|
||||||
|
HTTP server with the DML pool.
|
||||||
|
|
||||||
|
## Local development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env # adjust POSTGRES_PORT if 5432 is taken
|
||||||
|
npm install
|
||||||
|
npm run db:up # Postgres 18 via compose.yml
|
||||||
|
npm run migrate # apply migrations (DDL role)
|
||||||
|
npm run dev # http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
Health: `GET /healthz` (liveness), `GET /readyz` (DB + MQTT).
|
||||||
|
|
||||||
|
Endpoints (all require a Keycloak token — realm `mars3142`, issuer
|
||||||
|
`https://auth.mars3142.dev/realms/mars3142`, audience `findr-api` — with the
|
||||||
|
matching scope):
|
||||||
|
|
||||||
|
| Route | Scope |
|
||||||
|
| ---------------------------------------- | ---------------------------- |
|
||||||
|
| `GET\|POST /v1/parts`, `GET\|PATCH /v1/parts/:id` | `findr:parts:read` / `:write` |
|
||||||
|
| `POST /v1/pick/light\|idle\|off` | `findr:light:control` |
|
||||||
|
| `GET /v1/pick/boxes[/:number]` | `findr:parts:read` |
|
||||||
|
| `GET\|PUT /v1/controllers[/:espId]` | `findr:parts:read` / `:write` |
|
||||||
|
| `POST /v1/controllers/:espId/identify` | `findr:light:control` |
|
||||||
|
| `GET\|PUT /v1/boxes[/:number]` | `findr:parts:read` / `:write` |
|
||||||
|
| `PUT /v1/locations/:code/led` | `findr:parts:write` |
|
||||||
|
|
||||||
|
**Pick-by-light.** One ESP32 controller drives a WS2812 chain through several
|
||||||
|
boxes; each box owns a chain slice (`ledOffset`/`ledCount`) + a grid. Register
|
||||||
|
controllers and boxes via `/v1/controllers` + `/v1/boxes` (saving a box
|
||||||
|
republishes its retained MQTT config). Boxes idle on a rainbow; `POST
|
||||||
|
/v1/pick/light` resolves the requested compartments to LED indices and lights
|
||||||
|
them for `seconds`, then the box returns to idle. Full contract:
|
||||||
|
[`docs/mqtt.md`](docs/mqtt.md).
|
||||||
|
|
||||||
|
### Scopes (`src/lib/scopes.ts`)
|
||||||
|
|
||||||
|
| Scope | Grants |
|
||||||
|
| ---------------------- | ------------------------------------------------ |
|
||||||
|
| `findr:parts:read` | read parts, categories, locations, boxes, controllers, stock |
|
||||||
|
| `findr:parts:write` | create / edit parts, categories, locations, boxes, controllers |
|
||||||
|
| `findr:stock:write` | book stock movements (take / put / correction) |
|
||||||
|
| `findr:light:control` | drive pick-by-light LEDs, identify controllers |
|
||||||
|
|
||||||
|
These are Keycloak *client scopes* on the `findr-web` client; `findr-audience`
|
||||||
|
is the audience mapper that injects `findr-api` into `aud`.
|
||||||
|
|
||||||
|
### Scripts
|
||||||
|
|
||||||
|
| Script | Does |
|
||||||
|
| --------------------- | ---------------------------------------------- |
|
||||||
|
| `npm run dev` | watch-mode server (`tsx`) |
|
||||||
|
| `npm run build` | bundle to `dist/` (`tsup`) |
|
||||||
|
| `npm start` | run `dist/` (migrates, then serves) |
|
||||||
|
| `npm run migrate` | apply pending migrations with the DDL role |
|
||||||
|
| `npm run db:generate` | generate a migration from schema changes |
|
||||||
|
| `npm test` | unit tests (`vitest`) |
|
||||||
|
| `npm run lint` | `biome check` |
|
||||||
|
| `npm run typecheck` | `tsc --noEmit` |
|
||||||
|
|
||||||
|
## Schema changes
|
||||||
|
|
||||||
|
1. Edit `src/db/schema.ts`.
|
||||||
|
2. `npm run db:generate` → review the new file in `drizzle/`.
|
||||||
|
3. Commit schema + migration together.
|
||||||
|
4. Deploy — migrations apply automatically at container start.
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
`Dockerfile` is multi-stage (deps → build → prod-deps → runtime, non-root). The
|
||||||
|
container migrates then serves. findr runs on **mars3142-01** alongside the
|
||||||
|
Mosquitto broker; the API reaches it internally by service name over plain
|
||||||
|
`mqtt://mosquitto:1883` (no TLS — faster). Keycloak (`auth.mars3142.dev`) is on
|
||||||
|
mars3142-02. Production Postgres and the compose wiring live in the
|
||||||
|
`infrastructure` repo.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
See `.env.example`. Required: `DATABASE_URL`, `DATABASE_DDL_URL`,
|
||||||
|
`KEYCLOAK_ISSUER`, `KEYCLOAK_AUDIENCE`, `MQTT_URL`.
|
||||||
|
|
||||||
|
## Known advisories
|
||||||
|
|
||||||
|
`npm audit` reports 3 low-severity issues from `elliptic`, pulled in transitively
|
||||||
|
by `get-jwks` (JWKS verification). Keycloak signs with RSA by default; no fix is
|
||||||
|
available upstream yet.
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||||
|
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
|
||||||
|
"files": { "ignore": ["dist", "drizzle", "node_modules"] },
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"indentStyle": "space",
|
||||||
|
"indentWidth": 2,
|
||||||
|
"lineWidth": 100
|
||||||
|
},
|
||||||
|
"organizeImports": { "enabled": true },
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"recommended": true,
|
||||||
|
"style": {
|
||||||
|
"noNonNullAssertion": "off",
|
||||||
|
"useNamingConvention": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"javascript": {
|
||||||
|
"formatter": { "quoteStyle": "double", "trailingCommas": "all", "semicolons": "always" }
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
name: findr-api-dev
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
# Debian-based image on purpose: the Alpine variant is musl/libc-minimal and
|
||||||
|
# only ships C/POSIX libc locales, so linguistic sorting of German text is
|
||||||
|
# wrong. This image + the ICU locale provider below gives correct ordering
|
||||||
|
# and matches what runs in production on mars3142-01.
|
||||||
|
image: postgres:18
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_SUPERUSER_PASSWORD:-postgres}
|
||||||
|
POSTGRES_DB: findr
|
||||||
|
# ICU for collation, C.UTF-8 for the libc-side lc_ctype/lc_messages.
|
||||||
|
POSTGRES_INITDB_ARGS: "--locale-provider=icu --icu-locale=de-DE --locale=C.UTF-8 --encoding=UTF8"
|
||||||
|
FINDR_MIGRATOR_PASSWORD: ${FINDR_MIGRATOR_PASSWORD:-findr_migrator}
|
||||||
|
FINDR_APP_PASSWORD: ${FINDR_APP_PASSWORD:-findr_app}
|
||||||
|
ports:
|
||||||
|
- "${POSTGRES_PORT:-5432}:5432"
|
||||||
|
volumes:
|
||||||
|
# Postgres 18+ images: mount the parent dir, not /var/lib/postgresql/data.
|
||||||
|
- findr-pgdata:/var/lib/postgresql
|
||||||
|
- ./db/init:/docker-entrypoint-initdb.d:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres -d findr"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
findr-pgdata:
|
||||||
Executable
+43
@@ -0,0 +1,43 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Runs once, on first cluster initialisation, as the superuser, connected to
|
||||||
|
# POSTGRES_DB. Creates the two application roles and the "findr" schema.
|
||||||
|
#
|
||||||
|
# findr_migrator — DDL. Owns schema "findr" and every object in it. Used only
|
||||||
|
# to run migrations (at deploy time).
|
||||||
|
# findr_app — DML. May read/write rows but cannot change the schema.
|
||||||
|
#
|
||||||
|
# Passwords come from the environment (see compose.yml / .env).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
|
||||||
|
CREATE ROLE findr_migrator LOGIN PASSWORD '${FINDR_MIGRATOR_PASSWORD}';
|
||||||
|
CREATE ROLE findr_app LOGIN PASSWORD '${FINDR_APP_PASSWORD}';
|
||||||
|
|
||||||
|
-- Lock the database down; grant connect explicitly.
|
||||||
|
REVOKE ALL ON DATABASE "${POSTGRES_DB}" FROM PUBLIC;
|
||||||
|
GRANT CONNECT ON DATABASE "${POSTGRES_DB}" TO findr_migrator, findr_app;
|
||||||
|
|
||||||
|
-- Only the migrator may create schemas (the "findr" schema below plus the
|
||||||
|
-- "drizzle" migration-bookkeeping schema created on first migrate).
|
||||||
|
GRANT CREATE ON DATABASE "${POSTGRES_DB}" TO findr_migrator;
|
||||||
|
|
||||||
|
-- Application schema, owned by the migrator.
|
||||||
|
CREATE SCHEMA findr AUTHORIZATION findr_migrator;
|
||||||
|
|
||||||
|
-- Nothing lives in "public".
|
||||||
|
REVOKE ALL ON SCHEMA public FROM PUBLIC;
|
||||||
|
|
||||||
|
-- The app role may use the schema and touch data, but not alter it.
|
||||||
|
GRANT USAGE ON SCHEMA findr TO findr_app;
|
||||||
|
|
||||||
|
-- Objects the migrator creates in "findr" later are automatically usable
|
||||||
|
-- (DML only) by the app role.
|
||||||
|
ALTER DEFAULT PRIVILEGES FOR ROLE findr_migrator IN SCHEMA findr
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO findr_app;
|
||||||
|
ALTER DEFAULT PRIVILEGES FOR ROLE findr_migrator IN SCHEMA findr
|
||||||
|
GRANT USAGE, SELECT ON SEQUENCES TO findr_app;
|
||||||
|
|
||||||
|
-- Stable search_path for both roles.
|
||||||
|
ALTER ROLE findr_migrator IN DATABASE "${POSTGRES_DB}" SET search_path = findr, public;
|
||||||
|
ALTER ROLE findr_app IN DATABASE "${POSTGRES_DB}" SET search_path = findr, public;
|
||||||
|
EOSQL
|
||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
# MQTT contract — pick-by-light
|
||||||
|
|
||||||
|
Broker: **Mosquitto 2.1** on mars3142-01 (`mosquitto:1883` internal,
|
||||||
|
`mqtts://mqtt.mars3142.dev:8883` public). `allow_anonymous false`, password +
|
||||||
|
ACL file.
|
||||||
|
|
||||||
|
TypeScript source of truth: [`src/lib/mqtt-topics.ts`](../src/lib/mqtt-topics.ts).
|
||||||
|
|
||||||
|
## Model
|
||||||
|
|
||||||
|
- **Controller** — one ESP32-S3/C6 driving a single WS2812 chain. Identified by
|
||||||
|
`espId`. Can serve **several boxes**.
|
||||||
|
- **Box** — a sortiment box. Owns a contiguous slice of its controller's chain:
|
||||||
|
`[ledOffset, ledOffset + ledCount)`. Has a grid (`columns × rows`, `wiring`).
|
||||||
|
- **Cell** — a compartment ("D4"). Its LED = `ledOffset + withinBoxIndex`, where
|
||||||
|
`withinBoxIndex` is `locations.ledIndex` if set, else derived from the grid.
|
||||||
|
|
||||||
|
findr-api owns all topology. It publishes each box's config (retained) and
|
||||||
|
resolves cells → LED indices, so the firmware only needs its own `espId` and
|
||||||
|
chain length, then paints `chain[ledOffset + ledIndex]`.
|
||||||
|
|
||||||
|
### Effects
|
||||||
|
|
||||||
|
| effect | box's slice |
|
||||||
|
| ------ | --------------------------------------------- |
|
||||||
|
| `idle` | rainbow animation (resting state) |
|
||||||
|
| `pick` | only the listed bins lit (take=orange, put=green) |
|
||||||
|
| `off` | dark |
|
||||||
|
|
||||||
|
After a `pick` with `seconds`, the box returns to `idle` on its own.
|
||||||
|
|
||||||
|
## Topics
|
||||||
|
|
||||||
|
`<n>` = box number, `<esp>` = controller espId, prefix defaults to `findr`.
|
||||||
|
|
||||||
|
| Topic | Dir | Retain | Payload |
|
||||||
|
| ------------------------------------ | -------- | ------ | --------------- |
|
||||||
|
| `findr/box/<n>/config` | api → fw | **yes**| `BoxConfig` |
|
||||||
|
| `findr/box/<n>/cmd/light` | api → fw | no | `LightCommand` |
|
||||||
|
| `findr/box/<n>/state/light` | fw → api | **yes**| `LightState` |
|
||||||
|
| `findr/box/<n>/evt/button` | fw → api | no | `ButtonEvent` |
|
||||||
|
| `findr/controller/<esp>/state/online`| fw → \* | **yes**| `online`/`offline` (LWT) |
|
||||||
|
| `findr/controller/<esp>/state/info` | fw → api | **yes**| `ControllerInfo`|
|
||||||
|
| `findr/controller/<esp>/cmd/identify`| api → fw | no | `{ seconds }` |
|
||||||
|
|
||||||
|
### `BoxConfig` (retained)
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{ "boxNumber": 2, "controllerEspId": "a1b2c3", "ledOffset": 40,
|
||||||
|
"ledCount": 40, "columns": 8, "rows": 5, "wiring": "serpentine" }
|
||||||
|
```
|
||||||
|
|
||||||
|
The firmware keeps configs whose `controllerEspId` matches its own `espId`.
|
||||||
|
|
||||||
|
### `LightCommand`
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{ "effect": "pick",
|
||||||
|
"bins": [ { "cell": "D4", "ledIndex": 27, "mode": "take" } ],
|
||||||
|
"seconds": 30, "requestId": "uuid" }
|
||||||
|
```
|
||||||
|
|
||||||
|
`{ "effect": "idle" }` / `{ "effect": "off" }` carry no bins.
|
||||||
|
|
||||||
|
### `LightState` (retained)
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{ "effect": "pick", "lit": [ { "cell": "D4", "mode": "take" } ],
|
||||||
|
"requestId": "uuid", "ts": "2026-09-02T21:15:00Z" }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Broker users & ACL
|
||||||
|
|
||||||
|
Two users. Add to `/opt/docker/mosquitto/config/passwd`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker exec mosquitto mosquitto_passwd -b /mosquitto/config/passwd findr-api '<api-pw>'
|
||||||
|
docker exec mosquitto mosquitto_passwd -b /mosquitto/config/passwd findr-controller '<ctrl-pw>'
|
||||||
|
```
|
||||||
|
|
||||||
|
Append to `/opt/docker/mosquitto/config/acl.txt`:
|
||||||
|
|
||||||
|
```
|
||||||
|
user findr-api
|
||||||
|
topic write findr/box/+/config
|
||||||
|
topic write findr/box/+/cmd/#
|
||||||
|
topic write findr/controller/+/cmd/#
|
||||||
|
topic read findr/box/+/state/#
|
||||||
|
topic read findr/box/+/evt/#
|
||||||
|
topic read findr/controller/+/state/#
|
||||||
|
|
||||||
|
user findr-controller
|
||||||
|
topic read findr/box/+/config
|
||||||
|
topic read findr/box/+/cmd/#
|
||||||
|
topic read findr/controller/+/cmd/#
|
||||||
|
topic write findr/box/+/state/#
|
||||||
|
topic write findr/box/+/evt/#
|
||||||
|
topic write findr/controller/+/state/#
|
||||||
|
```
|
||||||
|
|
||||||
|
Reload without dropping connections: `docker kill -s HUP mosquitto`.
|
||||||
|
|
||||||
|
`findr-controller` is shared by every ESP32 for now. Once box provisioning is in
|
||||||
|
place, switch to per-controller users and scope with `%u`, e.g.
|
||||||
|
`pattern write findr/controller/%u/state/#`.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { defineConfig } from "drizzle-kit";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* drizzle-kit connects with the DDL role — it only ever generates/inspects
|
||||||
|
* schema. `DATABASE_DDL_URL` must be set (see .env.example).
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
dialect: "postgresql",
|
||||||
|
schema: "./src/db/schema.ts",
|
||||||
|
out: "./drizzle",
|
||||||
|
casing: "snake_case",
|
||||||
|
schemaFilter: ["findr"],
|
||||||
|
migrations: {
|
||||||
|
schema: "drizzle", // migration bookkeeping table lives in its own schema
|
||||||
|
},
|
||||||
|
dbCredentials: {
|
||||||
|
url: process.env.DATABASE_DDL_URL ?? "",
|
||||||
|
},
|
||||||
|
strict: true,
|
||||||
|
verbose: true,
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
-- Schema is pre-created (owned by findr_migrator) in db/init/01-roles.sh so that
|
||||||
|
-- default privileges for findr_app can be set before any migration runs.
|
||||||
|
CREATE SCHEMA IF NOT EXISTS "findr";
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TYPE "findr"."box_wiring" AS ENUM('progressive', 'serpentine');--> statement-breakpoint
|
||||||
|
CREATE TYPE "findr"."movement_kind" AS ENUM('take', 'put', 'correction');--> statement-breakpoint
|
||||||
|
CREATE TABLE "findr"."boxes" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"number" integer NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"controller_id" uuid,
|
||||||
|
"led_offset" integer DEFAULT 0 NOT NULL,
|
||||||
|
"led_count" integer DEFAULT 40 NOT NULL,
|
||||||
|
"columns" integer DEFAULT 8 NOT NULL,
|
||||||
|
"rows" integer DEFAULT 5 NOT NULL,
|
||||||
|
"wiring" "findr"."box_wiring" DEFAULT 'progressive' NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "boxes_number_unique" UNIQUE("number")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "findr"."categories" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"parent_id" uuid,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "categories_name_unique" UNIQUE("name")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "findr"."controllers" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"esp_id" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"led_count" integer NOT NULL,
|
||||||
|
"online" boolean DEFAULT false NOT NULL,
|
||||||
|
"last_seen_at" timestamp with time zone,
|
||||||
|
"firmware" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "controllers_esp_id_unique" UNIQUE("esp_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "findr"."locations" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"box_id" uuid NOT NULL,
|
||||||
|
"code" text NOT NULL,
|
||||||
|
"column" text NOT NULL,
|
||||||
|
"row" integer NOT NULL,
|
||||||
|
"led_index" integer,
|
||||||
|
"description" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "locations_code_unique" UNIQUE("code")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "findr"."part_locations" (
|
||||||
|
"part_id" uuid NOT NULL,
|
||||||
|
"location_id" uuid NOT NULL,
|
||||||
|
"quantity" integer DEFAULT 0 NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "part_locations_part_id_location_id_pk" PRIMARY KEY("part_id","location_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "findr"."parts" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"mpn" text NOT NULL,
|
||||||
|
"manufacturer" text,
|
||||||
|
"description" text DEFAULT '' NOT NULL,
|
||||||
|
"category_id" uuid,
|
||||||
|
"package" text,
|
||||||
|
"min_stock" integer DEFAULT 0 NOT NULL,
|
||||||
|
"lcsc_id" text,
|
||||||
|
"datasheet_url" text,
|
||||||
|
"photo_url" text,
|
||||||
|
"parameters" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "parts_lcsc_id_unique" UNIQUE("lcsc_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "findr"."stock_movements" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"part_id" uuid NOT NULL,
|
||||||
|
"location_id" uuid NOT NULL,
|
||||||
|
"kind" "findr"."movement_kind" NOT NULL,
|
||||||
|
"delta" integer NOT NULL,
|
||||||
|
"reason" text,
|
||||||
|
"actor" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "findr"."boxes" ADD CONSTRAINT "boxes_controller_id_controllers_id_fk" FOREIGN KEY ("controller_id") REFERENCES "findr"."controllers"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "findr"."locations" ADD CONSTRAINT "locations_box_id_boxes_id_fk" FOREIGN KEY ("box_id") REFERENCES "findr"."boxes"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "findr"."part_locations" ADD CONSTRAINT "part_locations_part_id_parts_id_fk" FOREIGN KEY ("part_id") REFERENCES "findr"."parts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "findr"."part_locations" ADD CONSTRAINT "part_locations_location_id_locations_id_fk" FOREIGN KEY ("location_id") REFERENCES "findr"."locations"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "findr"."parts" ADD CONSTRAINT "parts_category_id_categories_id_fk" FOREIGN KEY ("category_id") REFERENCES "findr"."categories"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "findr"."stock_movements" ADD CONSTRAINT "stock_movements_part_id_parts_id_fk" FOREIGN KEY ("part_id") REFERENCES "findr"."parts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "findr"."stock_movements" ADD CONSTRAINT "stock_movements_location_id_locations_id_fk" FOREIGN KEY ("location_id") REFERENCES "findr"."locations"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "boxes_controller_offset_uq" ON "findr"."boxes" USING btree ("controller_id","led_offset");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "locations_box_cell_uq" ON "findr"."locations" USING btree ("box_id","column","row");
|
||||||
@@ -0,0 +1,721 @@
|
|||||||
|
{
|
||||||
|
"id": "14d1259d-db05-47c7-b07e-000655101de3",
|
||||||
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"findr.boxes": {
|
||||||
|
"name": "boxes",
|
||||||
|
"schema": "findr",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"number": {
|
||||||
|
"name": "number",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"controller_id": {
|
||||||
|
"name": "controller_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"led_offset": {
|
||||||
|
"name": "led_offset",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"led_count": {
|
||||||
|
"name": "led_count",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 40
|
||||||
|
},
|
||||||
|
"columns": {
|
||||||
|
"name": "columns",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 8
|
||||||
|
},
|
||||||
|
"rows": {
|
||||||
|
"name": "rows",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 5
|
||||||
|
},
|
||||||
|
"wiring": {
|
||||||
|
"name": "wiring",
|
||||||
|
"type": "box_wiring",
|
||||||
|
"typeSchema": "findr",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'progressive'"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"boxes_controller_offset_uq": {
|
||||||
|
"name": "boxes_controller_offset_uq",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "controller_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "led_offset",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"boxes_controller_id_controllers_id_fk": {
|
||||||
|
"name": "boxes_controller_id_controllers_id_fk",
|
||||||
|
"tableFrom": "boxes",
|
||||||
|
"tableTo": "controllers",
|
||||||
|
"schemaTo": "findr",
|
||||||
|
"columnsFrom": [
|
||||||
|
"controller_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "set null",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"boxes_number_unique": {
|
||||||
|
"name": "boxes_number_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"number"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"findr.categories": {
|
||||||
|
"name": "categories",
|
||||||
|
"schema": "findr",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"parent_id": {
|
||||||
|
"name": "parent_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"categories_name_unique": {
|
||||||
|
"name": "categories_name_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"name"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"findr.controllers": {
|
||||||
|
"name": "controllers",
|
||||||
|
"schema": "findr",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"esp_id": {
|
||||||
|
"name": "esp_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"led_count": {
|
||||||
|
"name": "led_count",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"online": {
|
||||||
|
"name": "online",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"last_seen_at": {
|
||||||
|
"name": "last_seen_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"firmware": {
|
||||||
|
"name": "firmware",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"controllers_esp_id_unique": {
|
||||||
|
"name": "controllers_esp_id_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"esp_id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"findr.locations": {
|
||||||
|
"name": "locations",
|
||||||
|
"schema": "findr",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"box_id": {
|
||||||
|
"name": "box_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"code": {
|
||||||
|
"name": "code",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"column": {
|
||||||
|
"name": "column",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"row": {
|
||||||
|
"name": "row",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"led_index": {
|
||||||
|
"name": "led_index",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"locations_box_cell_uq": {
|
||||||
|
"name": "locations_box_cell_uq",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "box_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "column",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "row",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"locations_box_id_boxes_id_fk": {
|
||||||
|
"name": "locations_box_id_boxes_id_fk",
|
||||||
|
"tableFrom": "locations",
|
||||||
|
"tableTo": "boxes",
|
||||||
|
"schemaTo": "findr",
|
||||||
|
"columnsFrom": [
|
||||||
|
"box_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "restrict",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"locations_code_unique": {
|
||||||
|
"name": "locations_code_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"findr.part_locations": {
|
||||||
|
"name": "part_locations",
|
||||||
|
"schema": "findr",
|
||||||
|
"columns": {
|
||||||
|
"part_id": {
|
||||||
|
"name": "part_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"location_id": {
|
||||||
|
"name": "location_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"quantity": {
|
||||||
|
"name": "quantity",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"part_locations_part_id_parts_id_fk": {
|
||||||
|
"name": "part_locations_part_id_parts_id_fk",
|
||||||
|
"tableFrom": "part_locations",
|
||||||
|
"tableTo": "parts",
|
||||||
|
"schemaTo": "findr",
|
||||||
|
"columnsFrom": [
|
||||||
|
"part_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"part_locations_location_id_locations_id_fk": {
|
||||||
|
"name": "part_locations_location_id_locations_id_fk",
|
||||||
|
"tableFrom": "part_locations",
|
||||||
|
"tableTo": "locations",
|
||||||
|
"schemaTo": "findr",
|
||||||
|
"columnsFrom": [
|
||||||
|
"location_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "restrict",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"part_locations_part_id_location_id_pk": {
|
||||||
|
"name": "part_locations_part_id_location_id_pk",
|
||||||
|
"columns": [
|
||||||
|
"part_id",
|
||||||
|
"location_id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"findr.parts": {
|
||||||
|
"name": "parts",
|
||||||
|
"schema": "findr",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"mpn": {
|
||||||
|
"name": "mpn",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"manufacturer": {
|
||||||
|
"name": "manufacturer",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "''"
|
||||||
|
},
|
||||||
|
"category_id": {
|
||||||
|
"name": "category_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"package": {
|
||||||
|
"name": "package",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"min_stock": {
|
||||||
|
"name": "min_stock",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"lcsc_id": {
|
||||||
|
"name": "lcsc_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"datasheet_url": {
|
||||||
|
"name": "datasheet_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"photo_url": {
|
||||||
|
"name": "photo_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"parameters": {
|
||||||
|
"name": "parameters",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"parts_category_id_categories_id_fk": {
|
||||||
|
"name": "parts_category_id_categories_id_fk",
|
||||||
|
"tableFrom": "parts",
|
||||||
|
"tableTo": "categories",
|
||||||
|
"schemaTo": "findr",
|
||||||
|
"columnsFrom": [
|
||||||
|
"category_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "set null",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"parts_lcsc_id_unique": {
|
||||||
|
"name": "parts_lcsc_id_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"lcsc_id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"findr.stock_movements": {
|
||||||
|
"name": "stock_movements",
|
||||||
|
"schema": "findr",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"part_id": {
|
||||||
|
"name": "part_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"location_id": {
|
||||||
|
"name": "location_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"name": "kind",
|
||||||
|
"type": "movement_kind",
|
||||||
|
"typeSchema": "findr",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"delta": {
|
||||||
|
"name": "delta",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"reason": {
|
||||||
|
"name": "reason",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"actor": {
|
||||||
|
"name": "actor",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"stock_movements_part_id_parts_id_fk": {
|
||||||
|
"name": "stock_movements_part_id_parts_id_fk",
|
||||||
|
"tableFrom": "stock_movements",
|
||||||
|
"tableTo": "parts",
|
||||||
|
"schemaTo": "findr",
|
||||||
|
"columnsFrom": [
|
||||||
|
"part_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"stock_movements_location_id_locations_id_fk": {
|
||||||
|
"name": "stock_movements_location_id_locations_id_fk",
|
||||||
|
"tableFrom": "stock_movements",
|
||||||
|
"tableTo": "locations",
|
||||||
|
"schemaTo": "findr",
|
||||||
|
"columnsFrom": [
|
||||||
|
"location_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "restrict",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {
|
||||||
|
"findr.box_wiring": {
|
||||||
|
"name": "box_wiring",
|
||||||
|
"schema": "findr",
|
||||||
|
"values": [
|
||||||
|
"progressive",
|
||||||
|
"serpentine"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"findr.movement_kind": {
|
||||||
|
"name": "movement_kind",
|
||||||
|
"schema": "findr",
|
||||||
|
"values": [
|
||||||
|
"take",
|
||||||
|
"put",
|
||||||
|
"correction"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schemas": {
|
||||||
|
"findr": "findr"
|
||||||
|
},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1788384198271,
|
||||||
|
"tag": "0000_init",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Generated
+6193
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"name": "findr-api",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch --env-file-if-exists=.env src/index.ts",
|
||||||
|
"build": "tsup",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"migrate": "tsx --env-file-if-exists=.env src/db/migrate.ts",
|
||||||
|
"db:generate": "drizzle-kit generate",
|
||||||
|
"db:up": "docker compose up -d postgres",
|
||||||
|
"db:down": "docker compose down",
|
||||||
|
"lint": "biome check .",
|
||||||
|
"format": "biome format --write .",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/cors": "^10.0.1",
|
||||||
|
"@fastify/helmet": "^12.0.1",
|
||||||
|
"@fastify/jwt": "^10.2.2",
|
||||||
|
"@fastify/sensible": "^6.0.1",
|
||||||
|
"close-with-grace": "^2.1.0",
|
||||||
|
"drizzle-orm": "^0.45.2",
|
||||||
|
"fastify": "^5.12.1",
|
||||||
|
"fastify-plugin": "^5.0.1",
|
||||||
|
"get-jwks": "^11.0.3",
|
||||||
|
"mqtt": "^5.15.2",
|
||||||
|
"postgres": "^3.4.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "^1.9.4",
|
||||||
|
"@types/node": "^22.10.1",
|
||||||
|
"drizzle-kit": "^0.31.10",
|
||||||
|
"pino-pretty": "^13.0.0",
|
||||||
|
"tsup": "^8.5.1",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"vitest": "^4.1.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
+70
@@ -0,0 +1,70 @@
|
|||||||
|
import cors from "@fastify/cors";
|
||||||
|
import helmet from "@fastify/helmet";
|
||||||
|
import sensible from "@fastify/sensible";
|
||||||
|
import Fastify, { type FastifyError, type FastifyInstance } from "fastify";
|
||||||
|
import { config } from "./config.js";
|
||||||
|
import { AppError } from "./lib/errors.js";
|
||||||
|
import authPlugin from "./plugins/auth.js";
|
||||||
|
import dbPlugin from "./plugins/db.js";
|
||||||
|
import mqttPlugin from "./plugins/mqtt.js";
|
||||||
|
import pickByLightPlugin from "./plugins/pick-by-light.js";
|
||||||
|
import boxSetupRoutes from "./routes/boxes.js";
|
||||||
|
import healthRoutes from "./routes/health.js";
|
||||||
|
import partsRoutes from "./routes/parts.js";
|
||||||
|
import pickRoutes from "./routes/pick.js";
|
||||||
|
|
||||||
|
export interface BuildAppOptions {
|
||||||
|
/** Skip external connections (db/mqtt) — used by unit tests. */
|
||||||
|
withInfra?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildApp(opts: BuildAppOptions = {}): Promise<FastifyInstance> {
|
||||||
|
const withInfra = opts.withInfra ?? true;
|
||||||
|
|
||||||
|
const app = Fastify({
|
||||||
|
logger: {
|
||||||
|
level: config.http.logLevel,
|
||||||
|
transport: config.isProduction
|
||||||
|
? undefined
|
||||||
|
: { target: "pino-pretty", options: { translateTime: "HH:MM:ss", ignore: "pid,hostname" } },
|
||||||
|
},
|
||||||
|
trustProxy: true,
|
||||||
|
ajv: { customOptions: { coerceTypes: true, removeAdditional: "all" } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.register(sensible);
|
||||||
|
await app.register(helmet, { contentSecurityPolicy: false });
|
||||||
|
await app.register(cors, { origin: config.http.corsOrigin, credentials: true });
|
||||||
|
|
||||||
|
if (withInfra) {
|
||||||
|
await app.register(dbPlugin);
|
||||||
|
await app.register(mqttPlugin);
|
||||||
|
await app.register(pickByLightPlugin);
|
||||||
|
}
|
||||||
|
await app.register(authPlugin);
|
||||||
|
|
||||||
|
await app.register(healthRoutes);
|
||||||
|
await app.register(partsRoutes, { prefix: "/v1/parts" });
|
||||||
|
if (withInfra) {
|
||||||
|
await app.register(pickRoutes, { prefix: "/v1/pick" });
|
||||||
|
await app.register(boxSetupRoutes, { prefix: "/v1" });
|
||||||
|
}
|
||||||
|
|
||||||
|
app.setErrorHandler((error: FastifyError, request, reply) => {
|
||||||
|
if (error instanceof AppError) {
|
||||||
|
return reply.code(error.statusCode).send({ error: error.code, message: error.message });
|
||||||
|
}
|
||||||
|
if (error.validation) {
|
||||||
|
return reply.code(400).send({ error: "bad_request", message: error.message });
|
||||||
|
}
|
||||||
|
if (error.statusCode && error.statusCode < 500) {
|
||||||
|
return reply
|
||||||
|
.code(error.statusCode)
|
||||||
|
.send({ error: error.code ?? "error", message: error.message });
|
||||||
|
}
|
||||||
|
request.log.error({ err: error }, "unhandled error");
|
||||||
|
return reply.code(500).send({ error: "internal", message: "Internal Server Error" });
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* Environment configuration. Validated once at process start; import the frozen
|
||||||
|
* `config` object everywhere else. Used by both the HTTP server and the
|
||||||
|
* standalone migration script.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type NodeEnv = "development" | "test" | "production";
|
||||||
|
|
||||||
|
function req(name: string): string {
|
||||||
|
const v = process.env[name];
|
||||||
|
if (v === undefined || v === "") {
|
||||||
|
throw new Error(`Missing required environment variable: ${name}`);
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function opt(name: string, fallback: string): string {
|
||||||
|
const v = process.env[name];
|
||||||
|
return v === undefined || v === "" ? fallback : v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bool(name: string, fallback: boolean): boolean {
|
||||||
|
const v = process.env[name];
|
||||||
|
if (v === undefined || v === "") return fallback;
|
||||||
|
return v === "true" || v === "1";
|
||||||
|
}
|
||||||
|
|
||||||
|
function int(name: string, fallback: number): number {
|
||||||
|
const v = process.env[name];
|
||||||
|
if (v === undefined || v === "") return fallback;
|
||||||
|
const n = Number.parseInt(v, 10);
|
||||||
|
if (Number.isNaN(n)) throw new Error(`Environment variable ${name} must be an integer`);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeEnv = opt("NODE_ENV", "development") as NodeEnv;
|
||||||
|
|
||||||
|
export const config = Object.freeze({
|
||||||
|
nodeEnv,
|
||||||
|
isProduction: nodeEnv === "production",
|
||||||
|
isTest: nodeEnv === "test",
|
||||||
|
|
||||||
|
http: Object.freeze({
|
||||||
|
host: opt("HOST", "0.0.0.0"),
|
||||||
|
port: int("PORT", 3000),
|
||||||
|
logLevel: opt("LOG_LEVEL", "info"),
|
||||||
|
corsOrigin: opt("CORS_ORIGIN", "http://localhost:5173")
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
}),
|
||||||
|
|
||||||
|
db: Object.freeze({
|
||||||
|
/** Runtime connection — DML role (findr_app). */
|
||||||
|
url: req("DATABASE_URL"),
|
||||||
|
/** Migration connection — DDL role (findr_migrator). */
|
||||||
|
ddlUrl: req("DATABASE_DDL_URL"),
|
||||||
|
/** Postgres schema the app owns. */
|
||||||
|
schema: opt("DATABASE_SCHEMA", "findr"),
|
||||||
|
runMigrationsOnStart: bool("RUN_MIGRATIONS_ON_START", true),
|
||||||
|
}),
|
||||||
|
|
||||||
|
keycloak: Object.freeze({
|
||||||
|
issuer: req("KEYCLOAK_ISSUER"),
|
||||||
|
audience: req("KEYCLOAK_AUDIENCE"),
|
||||||
|
}),
|
||||||
|
|
||||||
|
mqtt: Object.freeze({
|
||||||
|
url: req("MQTT_URL"),
|
||||||
|
username: process.env.MQTT_USERNAME || undefined,
|
||||||
|
password: process.env.MQTT_PASSWORD || undefined,
|
||||||
|
topicPrefix: opt("MQTT_TOPIC_PREFIX", "findr"),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Config = typeof config;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
|
import postgres from "postgres";
|
||||||
|
import * as schema from "./schema.js";
|
||||||
|
|
||||||
|
export type Database = ReturnType<typeof createDatabase>["db"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a Drizzle client over a fresh postgres-js connection pool.
|
||||||
|
* Call `close()` on shutdown. `max` is small for the migrator (1) and larger
|
||||||
|
* for the runtime pool.
|
||||||
|
*/
|
||||||
|
export function createDatabase(url: string, options: { max?: number } = {}) {
|
||||||
|
const sql = postgres(url, {
|
||||||
|
max: options.max ?? 10,
|
||||||
|
// Drizzle handles types; keep transforms off for predictability.
|
||||||
|
prepare: true,
|
||||||
|
onnotice: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const db = drizzle(sql, { schema, casing: "snake_case" });
|
||||||
|
|
||||||
|
return {
|
||||||
|
db,
|
||||||
|
sql,
|
||||||
|
close: () => sql.end({ timeout: 5 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { schema };
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* CLI entrypoint for migrations: `npm run migrate` / `node dist/db/migrate.js`.
|
||||||
|
* Always runs; the reusable logic lives in run-migrations.ts.
|
||||||
|
*/
|
||||||
|
import { runMigrations } from "./run-migrations.js";
|
||||||
|
|
||||||
|
runMigrations()
|
||||||
|
.then(() => {
|
||||||
|
console.log("migrations: up to date");
|
||||||
|
process.exit(0);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("migrations: failed", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { migrate } from "drizzle-orm/postgres-js/migrator";
|
||||||
|
import { config } from "../config.js";
|
||||||
|
import { createDatabase } from "./client.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs every pending Drizzle migration using the DDL role (findr_migrator),
|
||||||
|
* which owns the `findr` schema. The runtime never connects with this role.
|
||||||
|
*
|
||||||
|
* Invoked by `npm run migrate` and automatically at container start on every
|
||||||
|
* deployment (see src/index.ts), before the HTTP server begins listening.
|
||||||
|
*/
|
||||||
|
export async function runMigrations(): Promise<void> {
|
||||||
|
const { db, close } = createDatabase(config.db.ddlUrl, { max: 1 });
|
||||||
|
try {
|
||||||
|
await migrate(db, { migrationsFolder: "drizzle", migrationsSchema: "drizzle" });
|
||||||
|
} finally {
|
||||||
|
await close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
/**
|
||||||
|
* Drizzle schema. Everything lives in the `findr` schema, which is owned by the
|
||||||
|
* DDL role (findr_migrator). The runtime role (findr_app) only gets DML.
|
||||||
|
*
|
||||||
|
* This is a first slice of the findr domain — enough to wire one resource
|
||||||
|
* (parts) through every layer. See Findr.pdf for the full picture.
|
||||||
|
*/
|
||||||
|
import { relations } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
boolean,
|
||||||
|
integer,
|
||||||
|
pgSchema,
|
||||||
|
primaryKey,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
uniqueIndex,
|
||||||
|
uuid,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
export const findr = pgSchema("findr");
|
||||||
|
|
||||||
|
const timestamps = {
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow()
|
||||||
|
.$onUpdate(() => new Date()),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** take = light a bin orange to remove parts, put = green to store parts. */
|
||||||
|
export const movementKind = findr.enum("movement_kind", ["take", "put", "correction"]);
|
||||||
|
|
||||||
|
/** LED matrix wiring within a box. */
|
||||||
|
export const boxWiring = findr.enum("box_wiring", ["progressive", "serpentine"]);
|
||||||
|
|
||||||
|
// ── categories ────────────────────────────────────────────────────────────────
|
||||||
|
export const categories = findr.table("categories", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
name: text("name").notNull().unique(),
|
||||||
|
parentId: uuid("parent_id"),
|
||||||
|
...timestamps,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const categoriesRelations = relations(categories, ({ one, many }) => ({
|
||||||
|
parent: one(categories, {
|
||||||
|
fields: [categories.parentId],
|
||||||
|
references: [categories.id],
|
||||||
|
relationName: "category_tree",
|
||||||
|
}),
|
||||||
|
children: many(categories, { relationName: "category_tree" }),
|
||||||
|
parts: many(parts),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── controllers (ESP32) ───────────────────────────────────────────────────────
|
||||||
|
// One ESP32-S3/C6 drives a single WS2812 chain that can run through SEVERAL
|
||||||
|
// boxes. `espId` is the hardware chip id and the MQTT identity of the device.
|
||||||
|
export const controllers = findr.table("controllers", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
espId: text("esp_id").notNull().unique(),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
/** Total number of LEDs on this controller's chain. */
|
||||||
|
ledCount: integer("led_count").notNull(),
|
||||||
|
online: boolean("online").notNull().default(false),
|
||||||
|
lastSeenAt: timestamp("last_seen_at", { withTimezone: true }),
|
||||||
|
firmware: text("firmware"),
|
||||||
|
...timestamps,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const controllersRelations = relations(controllers, ({ many }) => ({
|
||||||
|
boxes: many(boxes),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── boxes (Kästen) ────────────────────────────────────────────────────────────
|
||||||
|
// One physical sortiment box. Its LEDs are a contiguous run on its controller's
|
||||||
|
// chain: [ledOffset, ledOffset + ledCount). `number` is the "K2" in "K2·D4".
|
||||||
|
export const boxes = findr.table(
|
||||||
|
"boxes",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
number: integer("number").notNull().unique(),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
controllerId: uuid("controller_id").references(() => controllers.id, { onDelete: "set null" }),
|
||||||
|
/** First LED index of this box on the controller chain. */
|
||||||
|
ledOffset: integer("led_offset").notNull().default(0),
|
||||||
|
/** How many LEDs this box uses (one per compartment). */
|
||||||
|
ledCount: integer("led_count").notNull().default(40),
|
||||||
|
/** Grid used to auto-map a compartment to an LED when locations.ledIndex is null. */
|
||||||
|
columns: integer("columns").notNull().default(8),
|
||||||
|
rows: integer("rows").notNull().default(5),
|
||||||
|
wiring: boxWiring("wiring").notNull().default("progressive"),
|
||||||
|
...timestamps,
|
||||||
|
},
|
||||||
|
(t) => [uniqueIndex("boxes_controller_offset_uq").on(t.controllerId, t.ledOffset)],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const boxesRelations = relations(boxes, ({ one, many }) => ({
|
||||||
|
controller: one(controllers, { fields: [boxes.controllerId], references: [controllers.id] }),
|
||||||
|
locations: many(locations),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── locations (Fächer) ────────────────────────────────────────────────────────
|
||||||
|
// A compartment inside a box. `code` is the human label ("K2·D4"); `column`/`row`
|
||||||
|
// address the LED cell. A part can occupy several locations at once.
|
||||||
|
export const locations = findr.table(
|
||||||
|
"locations",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
boxId: uuid("box_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => boxes.id, { onDelete: "restrict" }),
|
||||||
|
code: text("code").notNull().unique(),
|
||||||
|
column: text("column").notNull(),
|
||||||
|
row: integer("row").notNull(),
|
||||||
|
/** LED index within the box (0-based). Null → derived from the box grid. */
|
||||||
|
ledIndex: integer("led_index"),
|
||||||
|
description: text("description"),
|
||||||
|
...timestamps,
|
||||||
|
},
|
||||||
|
(t) => [uniqueIndex("locations_box_cell_uq").on(t.boxId, t.column, t.row)],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const locationsRelations = relations(locations, ({ one, many }) => ({
|
||||||
|
box: one(boxes, { fields: [locations.boxId], references: [boxes.id] }),
|
||||||
|
partLocations: many(partLocations),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── parts (Bauteile) ──────────────────────────────────────────────────────────
|
||||||
|
export const parts = findr.table("parts", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
mpn: text("mpn").notNull(),
|
||||||
|
manufacturer: text("manufacturer"),
|
||||||
|
description: text("description").notNull().default(""),
|
||||||
|
categoryId: uuid("category_id").references(() => categories.id, { onDelete: "set null" }),
|
||||||
|
package: text("package"),
|
||||||
|
minStock: integer("min_stock").notNull().default(0),
|
||||||
|
lcscId: text("lcsc_id").unique(),
|
||||||
|
datasheetUrl: text("datasheet_url"),
|
||||||
|
photoUrl: text("photo_url"),
|
||||||
|
parameters: text("parameters"), // JSON blob for now; promote to a table later.
|
||||||
|
...timestamps,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const partsRelations = relations(parts, ({ one, many }) => ({
|
||||||
|
category: one(categories, { fields: [parts.categoryId], references: [categories.id] }),
|
||||||
|
locations: many(partLocations),
|
||||||
|
movements: many(stockMovements),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── part_locations (Bestand je Lagerort) ──────────────────────────────────────
|
||||||
|
export const partLocations = findr.table(
|
||||||
|
"part_locations",
|
||||||
|
{
|
||||||
|
partId: uuid("part_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => parts.id, { onDelete: "cascade" }),
|
||||||
|
locationId: uuid("location_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => locations.id, { onDelete: "restrict" }),
|
||||||
|
quantity: integer("quantity").notNull().default(0),
|
||||||
|
...timestamps,
|
||||||
|
},
|
||||||
|
(t) => [primaryKey({ columns: [t.partId, t.locationId] })],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const partLocationsRelations = relations(partLocations, ({ one }) => ({
|
||||||
|
part: one(parts, { fields: [partLocations.partId], references: [parts.id] }),
|
||||||
|
location: one(locations, { fields: [partLocations.locationId], references: [locations.id] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── stock_movements (Verlauf) ─────────────────────────────────────────────────
|
||||||
|
export const stockMovements = findr.table("stock_movements", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
partId: uuid("part_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => parts.id, { onDelete: "cascade" }),
|
||||||
|
locationId: uuid("location_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => locations.id, { onDelete: "restrict" }),
|
||||||
|
kind: movementKind("kind").notNull(),
|
||||||
|
delta: integer("delta").notNull(),
|
||||||
|
reason: text("reason"),
|
||||||
|
actor: text("actor"), // Keycloak subject that booked the movement.
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const stockMovementsRelations = relations(stockMovements, ({ one }) => ({
|
||||||
|
part: one(parts, { fields: [stockMovements.partId], references: [parts.id] }),
|
||||||
|
location: one(locations, { fields: [stockMovements.locationId], references: [locations.id] }),
|
||||||
|
}));
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import closeWithGrace from "close-with-grace";
|
||||||
|
import { buildApp } from "./app.js";
|
||||||
|
import { config } from "./config.js";
|
||||||
|
import { runMigrations } from "./db/run-migrations.js";
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
// Every deployment migrates first, with the DDL role, before serving traffic.
|
||||||
|
if (config.db.runMigrationsOnStart) {
|
||||||
|
await runMigrations();
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = await buildApp();
|
||||||
|
|
||||||
|
closeWithGrace({ delay: 10_000 }, async ({ err }) => {
|
||||||
|
if (err) app.log.error({ err }, "shutting down after error");
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.listen({ host: config.http.host, port: config.http.port });
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Framework-agnostic domain errors. The service and repository layers throw
|
||||||
|
* these; the HTTP layer maps them to status codes (see src/app.ts).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class AppError extends Error {
|
||||||
|
readonly statusCode: number;
|
||||||
|
readonly code: string;
|
||||||
|
|
||||||
|
constructor(message: string, statusCode: number, code: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = new.target.name;
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NotFoundError extends AppError {
|
||||||
|
constructor(resource: string, id?: string) {
|
||||||
|
super(id ? `${resource} ${id} not found` : `${resource} not found`, 404, "not_found");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ConflictError extends AppError {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message, 409, "conflict");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ValidationError extends AppError {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message, 422, "validation_failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Mapping a compartment (column letter + row number) to an LED index within a
|
||||||
|
* box. One WS2812 chain can run through several boxes; each box owns a
|
||||||
|
* contiguous slice `[ledOffset, ledOffset + ledCount)` of that chain.
|
||||||
|
*
|
||||||
|
* absolute chain index = box.ledOffset + withinBoxIndex
|
||||||
|
*
|
||||||
|
* `withinBoxIndex` is either an explicit override on the location (set during
|
||||||
|
* setup, because dividers move) or derived from the box grid.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type BoxWiring = "progressive" | "serpentine";
|
||||||
|
|
||||||
|
export interface BoxGrid {
|
||||||
|
columns: number;
|
||||||
|
rows: number;
|
||||||
|
wiring: BoxWiring;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Column letter → 0-based index. "A" → 0, "B" → 1, … (single letter). */
|
||||||
|
export function columnIndex(column: string): number {
|
||||||
|
return column.trim().toUpperCase().charCodeAt(0) - 65;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Within-box LED index for a compartment, derived from the grid. */
|
||||||
|
export function gridLedIndex(column: string, row: number, grid: BoxGrid): number {
|
||||||
|
const col = columnIndex(column);
|
||||||
|
const r = row - 1;
|
||||||
|
// Serpentine matrices reverse every other row.
|
||||||
|
const pos = grid.wiring === "serpentine" && r % 2 === 1 ? grid.columns - 1 - col : col;
|
||||||
|
return r * grid.columns + pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Effective within-box LED index: explicit override wins, else the grid formula. */
|
||||||
|
export function ledIndexFor(
|
||||||
|
location: { column: string; row: number; ledIndex: number | null },
|
||||||
|
grid: BoxGrid,
|
||||||
|
): number {
|
||||||
|
return location.ledIndex ?? gridLedIndex(location.column, location.row, grid);
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* The MQTT contract between findr-api and the pick-by-light hardware.
|
||||||
|
*
|
||||||
|
* A **controller** is one ESP32-S3/C6 driving a single WS2812 chain that can run
|
||||||
|
* through several **boxes**. A box owns a contiguous slice of that chain
|
||||||
|
* (`ledOffset` … `ledOffset + ledCount`). A **cell** is a compartment ("D4").
|
||||||
|
*
|
||||||
|
* findr-api owns all topology: it publishes each box's config (retained) and
|
||||||
|
* resolves cells to LED indices, so the firmware only has to know its own
|
||||||
|
* `espId` and chain length, then paint `chain[box.ledOffset + ledIndex]`.
|
||||||
|
*
|
||||||
|
* A box is always in one effect:
|
||||||
|
* idle – rainbow animation over the box's slice (resting state)
|
||||||
|
* pick – only the listed bins lit (take = orange, put = green); rest dark
|
||||||
|
* off – slice dark
|
||||||
|
* After a `pick` with `seconds`, the box returns to `idle` by itself.
|
||||||
|
*
|
||||||
|
* Topics (prefix defaults to "findr"; `<n>` = box number, `<esp>` = controller espId):
|
||||||
|
*
|
||||||
|
* findr/box/<n>/config api → fw QoS1, RETAINED BoxConfig
|
||||||
|
* findr/box/<n>/cmd/light api → fw QoS1 LightCommand
|
||||||
|
* findr/box/<n>/state/light fw → api QoS1, RETAINED LightState
|
||||||
|
* findr/box/<n>/evt/button fw → api QoS1 ButtonEvent
|
||||||
|
* findr/controller/<esp>/state/online fw → * QoS1, RETAINED, LWT "online" | "offline"
|
||||||
|
* findr/controller/<esp>/state/info fw → api QoS1, RETAINED ControllerInfo
|
||||||
|
* findr/controller/<esp>/cmd/identify api → fw QoS1 { seconds?: number }
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type BinMode = "take" | "put";
|
||||||
|
export type BoxEffect = "idle" | "pick" | "off";
|
||||||
|
export type BoxWiring = "progressive" | "serpentine";
|
||||||
|
export type OnlineState = "online" | "offline";
|
||||||
|
|
||||||
|
/** One bin to light. `ledIndex` is resolved by findr-api (within-box, 0-based). */
|
||||||
|
export interface BinTarget {
|
||||||
|
cell: string;
|
||||||
|
ledIndex: number;
|
||||||
|
mode: BinMode;
|
||||||
|
/** Override colour (hex). The firmware picks a default from `mode` otherwise. */
|
||||||
|
color?: string;
|
||||||
|
blink?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retained on `box/<n>/config` — tells the firmware where the box lives. */
|
||||||
|
export interface BoxConfig {
|
||||||
|
boxNumber: number;
|
||||||
|
controllerEspId: string | null;
|
||||||
|
ledOffset: number;
|
||||||
|
ledCount: number;
|
||||||
|
columns: number;
|
||||||
|
rows: number;
|
||||||
|
wiring: BoxWiring;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Published to `box/<n>/cmd/light`. */
|
||||||
|
export interface LightCommand {
|
||||||
|
effect: BoxEffect;
|
||||||
|
/** Required when effect === "pick"; the complete set of lit bins. */
|
||||||
|
bins?: BinTarget[];
|
||||||
|
/** For "pick": auto-return to "idle" after N seconds. 0 / omitted = stay. */
|
||||||
|
seconds?: number;
|
||||||
|
/** Echoed back in LightState.requestId. */
|
||||||
|
requestId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retained on `box/<n>/state/light`. */
|
||||||
|
export interface LightState {
|
||||||
|
effect: BoxEffect;
|
||||||
|
lit: Array<{ cell: string; mode: BinMode }>;
|
||||||
|
requestId?: string;
|
||||||
|
ts: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ButtonEvent {
|
||||||
|
cell: string;
|
||||||
|
action: "confirm" | "cancel";
|
||||||
|
ts: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ControllerInfo {
|
||||||
|
espId: string;
|
||||||
|
firmware?: string;
|
||||||
|
ip?: string;
|
||||||
|
ledCount?: number;
|
||||||
|
uptimeS?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── topic builders ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function topics(prefix = "findr") {
|
||||||
|
const box = (n: number | "+") => `${prefix}/box/${n}`;
|
||||||
|
const ctrl = (esp: string) => `${prefix}/controller/${esp}`;
|
||||||
|
return {
|
||||||
|
prefix,
|
||||||
|
boxConfig: (n: number) => `${box(n)}/config`,
|
||||||
|
cmdLight: (n: number) => `${box(n)}/cmd/light`,
|
||||||
|
stateLight: (n: number | "+") => `${box(n)}/state/light`,
|
||||||
|
evtButton: (n: number | "+") => `${box(n)}/evt/button`,
|
||||||
|
ctrlOnline: (esp: string) => `${ctrl(esp)}/state/online`,
|
||||||
|
ctrlInfo: (esp: string) => `${ctrl(esp)}/state/info`,
|
||||||
|
ctrlIdentify: (esp: string) => `${ctrl(esp)}/cmd/identify`,
|
||||||
|
/** What findr-api subscribes to. */
|
||||||
|
allBoxState: () => `${prefix}/box/+/state/+`,
|
||||||
|
allBoxEvents: () => `${prefix}/box/+/evt/+`,
|
||||||
|
allControllerState: () => `${prefix}/controller/+/state/+`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pull the box number out of a `findr/box/<n>/...` topic, or null. */
|
||||||
|
export function boxNumberFromTopic(topic: string, prefix = "findr"): number | null {
|
||||||
|
const m = topic.match(new RegExp(`^${prefix}/box/(\\d+)/`));
|
||||||
|
return m?.[1] ? Number.parseInt(m[1], 10) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pull the controller espId out of a `findr/controller/<esp>/...` topic, or null. */
|
||||||
|
export function controllerEspIdFromTopic(topic: string, prefix = "findr"): string | null {
|
||||||
|
const m = topic.match(new RegExp(`^${prefix}/controller/([^/]+)/`));
|
||||||
|
return m?.[1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── command builders ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function pickCommand(
|
||||||
|
bins: BinTarget[],
|
||||||
|
opts: { seconds?: number; requestId?: string } = {},
|
||||||
|
): LightCommand {
|
||||||
|
return { effect: "pick", bins, seconds: opts.seconds, requestId: opts.requestId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function idleCommand(requestId?: string): LightCommand {
|
||||||
|
return { effect: "idle", requestId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function offCommand(requestId?: string): LightCommand {
|
||||||
|
return { effect: "off", requestId };
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* OAuth2 scopes issued by Keycloak (realm `mars3142`). Each is a Keycloak
|
||||||
|
* *client scope* assigned to the `findr-web` client; the `findr-audience`
|
||||||
|
* client scope (not listed here) is the audience mapper that puts `findr-api`
|
||||||
|
* into the token's `aud`.
|
||||||
|
*
|
||||||
|
* A logged-in findr-web session carries these in the space-delimited `scope`
|
||||||
|
* claim, e.g. "openid profile email findr:parts:read findr:parts:write ...".
|
||||||
|
*/
|
||||||
|
export const SCOPES = {
|
||||||
|
/** Read parts, categories, locations, boxes, stock levels. */
|
||||||
|
PARTS_READ: "findr:parts:read",
|
||||||
|
/** Create and edit parts / categories / locations. */
|
||||||
|
PARTS_WRITE: "findr:parts:write",
|
||||||
|
/** Book stock movements (take / put / correction). */
|
||||||
|
STOCK_WRITE: "findr:stock:write",
|
||||||
|
/** Drive the pick-by-light LEDs directly (without booking a movement). */
|
||||||
|
LIGHT_CONTROL: "findr:light:control",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type Scope = (typeof SCOPES)[keyof typeof SCOPES];
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import fastifyJwt from "@fastify/jwt";
|
||||||
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
|
import fp from "fastify-plugin";
|
||||||
|
import buildGetJwks from "get-jwks";
|
||||||
|
import { config } from "../config.js";
|
||||||
|
|
||||||
|
/** Shape of the Keycloak access token we rely on. */
|
||||||
|
interface KeycloakToken {
|
||||||
|
sub: string;
|
||||||
|
iss: string;
|
||||||
|
aud: string | string[];
|
||||||
|
/** OAuth2 scopes, space-delimited. */
|
||||||
|
scope?: string;
|
||||||
|
preferred_username?: string;
|
||||||
|
email?: string;
|
||||||
|
realm_access?: { roles?: string[] };
|
||||||
|
resource_access?: Record<string, { roles?: string[] }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "@fastify/jwt" {
|
||||||
|
interface FastifyJWT {
|
||||||
|
payload: KeycloakToken;
|
||||||
|
user: KeycloakToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "fastify" {
|
||||||
|
interface FastifyInstance {
|
||||||
|
/** preValidation hook: rejects the request with 401 unless a valid token is present. */
|
||||||
|
authenticate: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
||||||
|
/** Builds a preValidation hook that also requires a given scope (or client role). */
|
||||||
|
requireScope: (
|
||||||
|
scope: string,
|
||||||
|
) => (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
||||||
|
}
|
||||||
|
interface FastifyRequest {
|
||||||
|
hasScope: (scope: string) => boolean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenScopes(token: KeycloakToken): Set<string> {
|
||||||
|
const scopes = new Set<string>((token.scope ?? "").split(" ").filter(Boolean));
|
||||||
|
for (const role of token.realm_access?.roles ?? []) scopes.add(role);
|
||||||
|
for (const role of token.resource_access?.[config.keycloak.audience]?.roles ?? []) {
|
||||||
|
scopes.add(role);
|
||||||
|
}
|
||||||
|
return scopes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default fp(
|
||||||
|
async (app: FastifyInstance) => {
|
||||||
|
const getJwks = buildGetJwks({
|
||||||
|
issuersWhitelist: [config.keycloak.issuer],
|
||||||
|
providerDiscovery: true,
|
||||||
|
// Small cache; Keycloak rotates signing keys infrequently.
|
||||||
|
max: 10,
|
||||||
|
ttl: 10 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.register(fastifyJwt, {
|
||||||
|
decode: { complete: true },
|
||||||
|
secret: (_request: FastifyRequest, token: unknown) => {
|
||||||
|
const { header, payload } = token as {
|
||||||
|
header: { kid: string; alg: string };
|
||||||
|
payload: { iss: string };
|
||||||
|
};
|
||||||
|
return getJwks.getPublicKey({
|
||||||
|
kid: header.kid,
|
||||||
|
alg: header.alg,
|
||||||
|
domain: payload.iss,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
verify: {
|
||||||
|
allowedIss: config.keycloak.issuer,
|
||||||
|
allowedAud: config.keycloak.audience,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
app.decorate(
|
||||||
|
"authenticate",
|
||||||
|
async (request: FastifyRequest, reply: FastifyReply): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await request.jwtVerify();
|
||||||
|
} catch {
|
||||||
|
return reply
|
||||||
|
.code(401)
|
||||||
|
.send({ error: "unauthorized", message: "Missing or invalid access token" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.decorateRequest("hasScope", function (this: FastifyRequest, scope: string): boolean {
|
||||||
|
return tokenScopes(this.user).has(scope);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.decorate("requireScope", (scope: string) => {
|
||||||
|
return async (request: FastifyRequest, reply: FastifyReply): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await request.jwtVerify();
|
||||||
|
} catch {
|
||||||
|
return reply
|
||||||
|
.code(401)
|
||||||
|
.send({ error: "unauthorized", message: "Missing or invalid access token" });
|
||||||
|
}
|
||||||
|
if (!request.hasScope(scope)) {
|
||||||
|
return reply.code(403).send({ error: "forbidden", message: `Requires scope "${scope}"` });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ name: "auth", dependencies: [] },
|
||||||
|
);
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import fp from "fastify-plugin";
|
||||||
|
import { config } from "../config.js";
|
||||||
|
import { type Database, createDatabase } from "../db/client.js";
|
||||||
|
|
||||||
|
declare module "fastify" {
|
||||||
|
interface FastifyInstance {
|
||||||
|
/** Runtime Drizzle client — connects with the DML role (findr_app). */
|
||||||
|
db: Database;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decorates the app with a Drizzle client bound to the runtime (DML) role.
|
||||||
|
* Schema changes are never made here — see src/db/migrate.ts.
|
||||||
|
*/
|
||||||
|
export default fp(
|
||||||
|
async (app: FastifyInstance) => {
|
||||||
|
const { db, sql, close } = createDatabase(config.db.url, { max: 10 });
|
||||||
|
|
||||||
|
// Fail fast if the database is unreachable.
|
||||||
|
await sql`select 1`;
|
||||||
|
|
||||||
|
app.decorate("db", db);
|
||||||
|
app.addHook("onClose", async () => {
|
||||||
|
await close();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ name: "db" },
|
||||||
|
);
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { setTimeout as delay } from "node:timers/promises";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import fp from "fastify-plugin";
|
||||||
|
import mqtt, { type IClientPublishOptions, type MqttClient } from "mqtt";
|
||||||
|
import { config } from "../config.js";
|
||||||
|
|
||||||
|
export interface MqttPublishOptions {
|
||||||
|
qos?: 0 | 1 | 2;
|
||||||
|
retain?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handler for an incoming message. `payload` is parsed JSON, or the raw string. */
|
||||||
|
export type MqttMessageHandler = (topic: string, payload: unknown) => void | Promise<void>;
|
||||||
|
|
||||||
|
declare module "fastify" {
|
||||||
|
interface FastifyInstance {
|
||||||
|
mqtt: MqttClient;
|
||||||
|
/** Publish a JSON payload (QoS 1 by default). Resolves once the broker acks. */
|
||||||
|
mqttPublish: (topic: string, payload: unknown, opts?: MqttPublishOptions) => Promise<void>;
|
||||||
|
/** Subscribe to a topic filter and route matching messages to `handler`. */
|
||||||
|
mqttSubscribe: (filter: string, handler: MqttMessageHandler) => Promise<void>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function topicMatches(filter: string, topic: string): boolean {
|
||||||
|
const f = filter.split("/");
|
||||||
|
const t = topic.split("/");
|
||||||
|
for (let i = 0; i < f.length; i++) {
|
||||||
|
if (f[i] === "#") return true;
|
||||||
|
if (f[i] === "+") {
|
||||||
|
if (t[i] === undefined) return false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (f[i] !== t[i]) return false;
|
||||||
|
}
|
||||||
|
return f.length === t.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MQTT transport. Thin on purpose — feature logic (pick-by-light) lives in its
|
||||||
|
* own plugin. Non-blocking connect: the API boots even if the broker is briefly
|
||||||
|
* down; mqtt.js buffers publishes and reconnects on its own.
|
||||||
|
*/
|
||||||
|
export default fp(
|
||||||
|
async (app: FastifyInstance) => {
|
||||||
|
const routes: Array<{ filter: string; handler: MqttMessageHandler }> = [];
|
||||||
|
|
||||||
|
const client = mqtt.connect(config.mqtt.url, {
|
||||||
|
username: config.mqtt.username,
|
||||||
|
password: config.mqtt.password,
|
||||||
|
reconnectPeriod: 2000,
|
||||||
|
connectTimeout: 10_000,
|
||||||
|
clientId: `findr-api-${process.pid}`,
|
||||||
|
// We re-subscribe explicitly in the "connect" handler.
|
||||||
|
resubscribe: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on("connect", () => {
|
||||||
|
app.log.info("mqtt connected");
|
||||||
|
// (Re)subscribe every registered filter — covers the first connect and
|
||||||
|
// every reconnect, so a broker outage never blocks plugin startup.
|
||||||
|
for (const route of routes) {
|
||||||
|
client.subscribe(route.filter, { qos: 1 }, (err) => {
|
||||||
|
if (err) app.log.error({ err, filter: route.filter }, "mqtt subscribe failed");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
client.on("reconnect", () => app.log.warn("mqtt reconnecting"));
|
||||||
|
client.on("error", (err) => app.log.error({ err }, "mqtt error"));
|
||||||
|
|
||||||
|
client.on("message", (topic, buf) => {
|
||||||
|
let payload: unknown = buf.toString();
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(payload as string);
|
||||||
|
} catch {
|
||||||
|
/* keep raw string */
|
||||||
|
}
|
||||||
|
for (const route of routes) {
|
||||||
|
if (topicMatches(route.filter, topic)) {
|
||||||
|
Promise.resolve(route.handler(topic, payload)).catch((err) =>
|
||||||
|
app.log.error({ err, topic }, "mqtt handler failed"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.decorate("mqtt", client);
|
||||||
|
|
||||||
|
app.decorate(
|
||||||
|
"mqttPublish",
|
||||||
|
(topic: string, payload: unknown, opts: MqttPublishOptions = {}): Promise<void> => {
|
||||||
|
const body = typeof payload === "string" ? payload : JSON.stringify(payload);
|
||||||
|
const pubOpts: IClientPublishOptions = { qos: opts.qos ?? 1, retain: opts.retain ?? false };
|
||||||
|
return client.publishAsync(topic, body, pubOpts).then(() => undefined);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.decorate(
|
||||||
|
"mqttSubscribe",
|
||||||
|
async (filter: string, handler: MqttMessageHandler): Promise<void> => {
|
||||||
|
routes.push({ filter, handler });
|
||||||
|
// If already connected, subscribe now; otherwise the connect handler will.
|
||||||
|
if (client.connected) {
|
||||||
|
client.subscribe(filter, { qos: 1 }, (err) => {
|
||||||
|
if (err) app.log.error({ err, filter }, "mqtt subscribe failed");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.addHook("onClose", async () => {
|
||||||
|
await Promise.race([client.endAsync(), delay(3000)]);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ name: "mqtt" },
|
||||||
|
);
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import fp from "fastify-plugin";
|
||||||
|
import { config } from "../config.js";
|
||||||
|
import {
|
||||||
|
type BinTarget,
|
||||||
|
type BoxConfig,
|
||||||
|
type BoxEffect,
|
||||||
|
type ControllerInfo,
|
||||||
|
type LightCommand,
|
||||||
|
type LightState,
|
||||||
|
type OnlineState,
|
||||||
|
boxNumberFromTopic,
|
||||||
|
controllerEspIdFromTopic,
|
||||||
|
idleCommand,
|
||||||
|
offCommand,
|
||||||
|
pickCommand,
|
||||||
|
topics,
|
||||||
|
} from "../lib/mqtt-topics.js";
|
||||||
|
|
||||||
|
/** findr-api's live view of a box, from retained MQTT state. */
|
||||||
|
export interface BoxRuntime {
|
||||||
|
number: number;
|
||||||
|
effect: BoxEffect | "unknown";
|
||||||
|
lit: Array<{ cell: string; mode: "take" | "put" }>;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** findr-api's live view of a controller. */
|
||||||
|
export interface ControllerRuntime {
|
||||||
|
espId: string;
|
||||||
|
online: boolean;
|
||||||
|
info?: ControllerInfo;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PickOptions {
|
||||||
|
seconds?: number;
|
||||||
|
requestId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "fastify" {
|
||||||
|
interface FastifyInstance {
|
||||||
|
pickByLight: {
|
||||||
|
/** Light exactly these bins on a box; everything else goes dark. */
|
||||||
|
pick: (boxNumber: number, bins: BinTarget[], opts?: PickOptions) => Promise<void>;
|
||||||
|
/** Return a box to the rainbow idle animation. */
|
||||||
|
idle: (boxNumber: number) => Promise<void>;
|
||||||
|
/** Turn a box's slice fully off. */
|
||||||
|
off: (boxNumber: number) => Promise<void>;
|
||||||
|
/** (Re)publish a box's retained config for the firmware. */
|
||||||
|
publishBoxConfig: (cfg: BoxConfig) => Promise<void>;
|
||||||
|
/** Blink a controller's whole chain to locate it during setup. */
|
||||||
|
identify: (espId: string, seconds?: number) => Promise<void>;
|
||||||
|
boxState: (boxNumber: number) => BoxRuntime | undefined;
|
||||||
|
listBoxStates: () => BoxRuntime[];
|
||||||
|
controllerState: (espId: string) => ControllerRuntime | undefined;
|
||||||
|
listControllerStates: () => ControllerRuntime[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick-by-light component. Boxes idle on a rainbow; a pick lights only the
|
||||||
|
* target bin(s), then the box drops back to idle. findr-api keeps a live
|
||||||
|
* registry of boxes and controllers from the retained `state/*` topics.
|
||||||
|
*/
|
||||||
|
export default fp(
|
||||||
|
async (app: FastifyInstance) => {
|
||||||
|
const t = topics(config.mqtt.topicPrefix);
|
||||||
|
const boxes = new Map<number, BoxRuntime>();
|
||||||
|
const controllers = new Map<string, ControllerRuntime>();
|
||||||
|
|
||||||
|
const now = () => new Date().toISOString();
|
||||||
|
|
||||||
|
await app.mqttSubscribe(t.allBoxState(), (topic, payload) => {
|
||||||
|
const n = boxNumberFromTopic(topic, t.prefix);
|
||||||
|
if (n === null || !topic.endsWith("/state/light")) return;
|
||||||
|
const s = payload as Partial<LightState>;
|
||||||
|
boxes.set(n, {
|
||||||
|
number: n,
|
||||||
|
effect: s.effect ?? "unknown",
|
||||||
|
lit: Array.isArray(s.lit) ? s.lit : [],
|
||||||
|
updatedAt: now(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.mqttSubscribe(t.allControllerState(), (topic, payload) => {
|
||||||
|
const esp = controllerEspIdFromTopic(topic, t.prefix);
|
||||||
|
if (esp === null) return;
|
||||||
|
const rt = controllers.get(esp) ?? { espId: esp, online: false, updatedAt: now() };
|
||||||
|
if (topic.endsWith("/state/online")) {
|
||||||
|
rt.online = payload === ("online" satisfies OnlineState);
|
||||||
|
} else if (topic.endsWith("/state/info")) {
|
||||||
|
rt.info = payload as ControllerInfo;
|
||||||
|
}
|
||||||
|
rt.updatedAt = now();
|
||||||
|
controllers.set(esp, rt);
|
||||||
|
});
|
||||||
|
|
||||||
|
const send = (n: number, cmd: LightCommand) => app.mqttPublish(t.cmdLight(n), cmd, { qos: 1 });
|
||||||
|
|
||||||
|
app.decorate("pickByLight", {
|
||||||
|
pick: (n: number, bins: BinTarget[], opts: PickOptions = {}) =>
|
||||||
|
send(n, pickCommand(bins, opts)),
|
||||||
|
idle: (n: number) => send(n, idleCommand()),
|
||||||
|
off: (n: number) => send(n, offCommand()),
|
||||||
|
publishBoxConfig: (cfg: BoxConfig) =>
|
||||||
|
app.mqttPublish(t.boxConfig(cfg.boxNumber), cfg, { qos: 1, retain: true }),
|
||||||
|
identify: (espId: string, seconds = 5) =>
|
||||||
|
app.mqttPublish(t.ctrlIdentify(espId), { seconds }, { qos: 1 }),
|
||||||
|
boxState: (n: number) => boxes.get(n),
|
||||||
|
listBoxStates: () => [...boxes.values()],
|
||||||
|
controllerState: (espId: string) => controllers.get(espId),
|
||||||
|
listControllerStates: () => [...controllers.values()],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ name: "pick-by-light", dependencies: ["mqtt"] },
|
||||||
|
);
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { asc, eq } from "drizzle-orm";
|
||||||
|
import type { Database } from "../db/client.js";
|
||||||
|
import { boxes, controllers, locations } from "../db/schema.js";
|
||||||
|
|
||||||
|
export type Controller = typeof controllers.$inferSelect;
|
||||||
|
export type NewController = typeof controllers.$inferInsert;
|
||||||
|
export type Box = typeof boxes.$inferSelect;
|
||||||
|
export type NewBox = typeof boxes.$inferInsert;
|
||||||
|
export type Location = typeof locations.$inferSelect;
|
||||||
|
|
||||||
|
export interface BoxWithLocations extends Box {
|
||||||
|
locations: Location[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBoxesRepository(db: Database) {
|
||||||
|
return {
|
||||||
|
// ── controllers ──────────────────────────────────────────────────────────
|
||||||
|
listControllers(): Promise<Controller[]> {
|
||||||
|
return db.query.controllers.findMany({ orderBy: asc(controllers.name) });
|
||||||
|
},
|
||||||
|
|
||||||
|
findControllerById(id: string): Promise<Controller | undefined> {
|
||||||
|
return db.query.controllers.findFirst({ where: eq(controllers.id, id) });
|
||||||
|
},
|
||||||
|
|
||||||
|
findControllerByEspId(espId: string): Promise<Controller | undefined> {
|
||||||
|
return db.query.controllers.findFirst({ where: eq(controllers.espId, espId) });
|
||||||
|
},
|
||||||
|
|
||||||
|
async createController(data: NewController): Promise<Controller> {
|
||||||
|
const [row] = await db.insert(controllers).values(data).returning();
|
||||||
|
if (!row) throw new Error("insert into controllers returned no row");
|
||||||
|
return row;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateController(
|
||||||
|
id: string,
|
||||||
|
patch: Partial<NewController>,
|
||||||
|
): Promise<Controller | undefined> {
|
||||||
|
const [row] = await db
|
||||||
|
.update(controllers)
|
||||||
|
.set(patch)
|
||||||
|
.where(eq(controllers.id, id))
|
||||||
|
.returning();
|
||||||
|
return row;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Insert-or-update a controller keyed by espId (used on MQTT auto-discovery). */
|
||||||
|
async upsertControllerByEspId(
|
||||||
|
espId: string,
|
||||||
|
data: Omit<NewController, "espId">,
|
||||||
|
): Promise<Controller> {
|
||||||
|
const [row] = await db
|
||||||
|
.insert(controllers)
|
||||||
|
.values({ espId, ...data })
|
||||||
|
.onConflictDoUpdate({ target: controllers.espId, set: data })
|
||||||
|
.returning();
|
||||||
|
if (!row) throw new Error("upsert controllers returned no row");
|
||||||
|
return row;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── boxes ────────────────────────────────────────────────────────────────
|
||||||
|
listBoxes(): Promise<Box[]> {
|
||||||
|
return db.query.boxes.findMany({ orderBy: asc(boxes.number) });
|
||||||
|
},
|
||||||
|
|
||||||
|
boxesForController(controllerId: string): Promise<Box[]> {
|
||||||
|
return db.query.boxes.findMany({ where: eq(boxes.controllerId, controllerId) });
|
||||||
|
},
|
||||||
|
|
||||||
|
findBoxByNumber(n: number): Promise<BoxWithLocations | undefined> {
|
||||||
|
return db.query.boxes.findFirst({
|
||||||
|
where: eq(boxes.number, n),
|
||||||
|
with: { locations: true },
|
||||||
|
}) as Promise<BoxWithLocations | undefined>;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createBox(data: NewBox): Promise<Box> {
|
||||||
|
const [row] = await db.insert(boxes).values(data).returning();
|
||||||
|
if (!row) throw new Error("insert into boxes returned no row");
|
||||||
|
return row;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateBox(number: number, patch: Partial<NewBox>): Promise<Box | undefined> {
|
||||||
|
const [row] = await db.update(boxes).set(patch).where(eq(boxes.number, number)).returning();
|
||||||
|
return row;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── locations ────────────────────────────────────────────────────────────
|
||||||
|
async setLocationLedIndex(
|
||||||
|
code: string,
|
||||||
|
ledIndex: number | null,
|
||||||
|
): Promise<Location | undefined> {
|
||||||
|
const [row] = await db
|
||||||
|
.update(locations)
|
||||||
|
.set({ ledIndex })
|
||||||
|
.where(eq(locations.code, code))
|
||||||
|
.returning();
|
||||||
|
return row;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BoxesRepository = ReturnType<typeof createBoxesRepository>;
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { and, asc, eq, ilike, or, sql } from "drizzle-orm";
|
||||||
|
import type { Database } from "../db/client.js";
|
||||||
|
import { partLocations, parts } from "../db/schema.js";
|
||||||
|
|
||||||
|
export type Part = typeof parts.$inferSelect;
|
||||||
|
export type NewPart = typeof parts.$inferInsert;
|
||||||
|
|
||||||
|
export interface PartListItem extends Part {
|
||||||
|
totalStock: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListPartsParams {
|
||||||
|
search?: string;
|
||||||
|
belowMinimum?: boolean;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Correlated sum of all per-location quantities for a part. */
|
||||||
|
const totalStockSql = sql<number>`(
|
||||||
|
select coalesce(sum(${partLocations.quantity}), 0)::int
|
||||||
|
from ${partLocations}
|
||||||
|
where ${partLocations.partId} = ${parts.id}
|
||||||
|
)`;
|
||||||
|
|
||||||
|
export function createPartsRepository(db: Database) {
|
||||||
|
return {
|
||||||
|
async list(params: ListPartsParams): Promise<{ items: PartListItem[]; total: number }> {
|
||||||
|
const filters = [];
|
||||||
|
if (params.search) {
|
||||||
|
const needle = `%${params.search}%`;
|
||||||
|
filters.push(
|
||||||
|
or(
|
||||||
|
ilike(parts.mpn, needle),
|
||||||
|
ilike(parts.description, needle),
|
||||||
|
ilike(parts.manufacturer, needle),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (params.belowMinimum) {
|
||||||
|
filters.push(sql`${totalStockSql} < ${parts.minStock}`);
|
||||||
|
}
|
||||||
|
const where = filters.length ? and(...filters) : undefined;
|
||||||
|
|
||||||
|
const [items, [count]] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({ part: parts, totalStock: totalStockSql })
|
||||||
|
.from(parts)
|
||||||
|
.where(where)
|
||||||
|
.orderBy(asc(parts.mpn))
|
||||||
|
.limit(params.limit)
|
||||||
|
.offset(params.offset),
|
||||||
|
db.select({ n: sql<number>`count(*)::int` }).from(parts).where(where),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: items.map((r) => ({ ...r.part, totalStock: r.totalStock })),
|
||||||
|
total: count?.n ?? 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async findById(id: string): Promise<Part | undefined> {
|
||||||
|
return db.query.parts.findFirst({ where: eq(parts.id, id) });
|
||||||
|
},
|
||||||
|
|
||||||
|
async findByLcscId(lcscId: string): Promise<Part | undefined> {
|
||||||
|
return db.query.parts.findFirst({ where: eq(parts.lcscId, lcscId) });
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(data: NewPart): Promise<Part> {
|
||||||
|
const [row] = await db.insert(parts).values(data).returning();
|
||||||
|
if (!row) throw new Error("insert into parts returned no row");
|
||||||
|
return row;
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(id: string, patch: Partial<NewPart>): Promise<Part | undefined> {
|
||||||
|
const [row] = await db.update(parts).set(patch).where(eq(parts.id, id)).returning();
|
||||||
|
return row;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PartsRepository = ReturnType<typeof createPartsRepository>;
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { SCOPES } from "../lib/scopes.js";
|
||||||
|
import { createBoxesRepository } from "../repositories/boxes.repository.js";
|
||||||
|
import { createBoxesService } from "../services/boxes.service.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /v1 — box & controller setup.
|
||||||
|
*
|
||||||
|
* One controller (ESP32) drives a WS2812 chain running through several boxes.
|
||||||
|
* Each box owns a contiguous slice of the chain (ledOffset … ledOffset+ledCount)
|
||||||
|
* and a grid (columns × rows) used to auto-map a compartment to an LED when
|
||||||
|
* `locations.ledIndex` is not set explicitly. Saving a box (re)publishes its
|
||||||
|
* retained config to the firmware.
|
||||||
|
*/
|
||||||
|
export default async function boxSetupRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
const repo = createBoxesRepository(app.db);
|
||||||
|
const service = createBoxesService(repo);
|
||||||
|
|
||||||
|
// ── controllers ────────────────────────────────────────────────────────────
|
||||||
|
app.get("/controllers", { preValidation: app.requireScope(SCOPES.PARTS_READ) }, async () => {
|
||||||
|
const rows = await service.listControllers();
|
||||||
|
return {
|
||||||
|
controllers: rows.map((c) => ({
|
||||||
|
...c,
|
||||||
|
runtime: app.pickByLight.controllerState(c.espId) ?? null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put(
|
||||||
|
"/controllers/:espId",
|
||||||
|
{
|
||||||
|
preValidation: app.requireScope(SCOPES.PARTS_WRITE),
|
||||||
|
schema: {
|
||||||
|
params: {
|
||||||
|
type: "object",
|
||||||
|
required: ["espId"],
|
||||||
|
properties: { espId: { type: "string", minLength: 1, maxLength: 64 } },
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ["name", "ledCount"],
|
||||||
|
properties: {
|
||||||
|
name: { type: "string", minLength: 1, maxLength: 128 },
|
||||||
|
ledCount: { type: "integer", minimum: 1, maximum: 10000 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request) => {
|
||||||
|
const { espId } = request.params as { espId: string };
|
||||||
|
const { name, ledCount } = request.body as { name: string; ledCount: number };
|
||||||
|
return service.upsertController({ espId, name, ledCount });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
"/controllers/:espId/identify",
|
||||||
|
{
|
||||||
|
preValidation: app.requireScope(SCOPES.LIGHT_CONTROL),
|
||||||
|
schema: {
|
||||||
|
params: {
|
||||||
|
type: "object",
|
||||||
|
required: ["espId"],
|
||||||
|
properties: { espId: { type: "string", minLength: 1, maxLength: 64 } },
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: { seconds: { type: "integer", minimum: 1, maximum: 60, default: 5 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const { espId } = request.params as { espId: string };
|
||||||
|
const { seconds } = request.body as { seconds: number };
|
||||||
|
await app.pickByLight.identify(espId, seconds);
|
||||||
|
return reply.code(202).send({ espId, seconds });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── boxes ──────────────────────────────────────────────────────────────────
|
||||||
|
app.get("/boxes", { preValidation: app.requireScope(SCOPES.PARTS_READ) }, async () => ({
|
||||||
|
boxes: await service.listBoxes(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
"/boxes/:number",
|
||||||
|
{
|
||||||
|
preValidation: app.requireScope(SCOPES.PARTS_READ),
|
||||||
|
schema: {
|
||||||
|
params: {
|
||||||
|
type: "object",
|
||||||
|
required: ["number"],
|
||||||
|
properties: { number: { type: "integer", minimum: 1 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request) => {
|
||||||
|
const { number } = request.params as { number: number };
|
||||||
|
const box = await service.getBox(number);
|
||||||
|
return {
|
||||||
|
...box,
|
||||||
|
config: await service.boxConfig(number),
|
||||||
|
runtime: app.pickByLight.boxState(number) ?? null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.put(
|
||||||
|
"/boxes/:number",
|
||||||
|
{
|
||||||
|
preValidation: app.requireScope(SCOPES.PARTS_WRITE),
|
||||||
|
schema: {
|
||||||
|
params: {
|
||||||
|
type: "object",
|
||||||
|
required: ["number"],
|
||||||
|
properties: { number: { type: "integer", minimum: 1 } },
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ["name"],
|
||||||
|
properties: {
|
||||||
|
name: { type: "string", minLength: 1, maxLength: 128 },
|
||||||
|
controllerEspId: { type: ["string", "null"], maxLength: 64 },
|
||||||
|
ledOffset: { type: "integer", minimum: 0, maximum: 10000 },
|
||||||
|
ledCount: { type: "integer", minimum: 1, maximum: 2000 },
|
||||||
|
columns: { type: "integer", minimum: 1, maximum: 64 },
|
||||||
|
rows: { type: "integer", minimum: 1, maximum: 64 },
|
||||||
|
wiring: { type: "string", enum: ["progressive", "serpentine"] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request) => {
|
||||||
|
const { number } = request.params as { number: number };
|
||||||
|
const body = request.body as Record<string, unknown>;
|
||||||
|
const { box, config } = await service.upsertBox({ number, ...body } as never);
|
||||||
|
// Push the retained config so the firmware learns where this box lives.
|
||||||
|
await app.pickByLight.publishBoxConfig(config);
|
||||||
|
return { ...box, config };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.put(
|
||||||
|
"/locations/:code/led",
|
||||||
|
{
|
||||||
|
preValidation: app.requireScope(SCOPES.PARTS_WRITE),
|
||||||
|
schema: {
|
||||||
|
params: {
|
||||||
|
type: "object",
|
||||||
|
required: ["code"],
|
||||||
|
properties: { code: { type: "string", minLength: 1, maxLength: 32 } },
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ["ledIndex"],
|
||||||
|
properties: { ledIndex: { type: ["integer", "null"], minimum: 0, maximum: 2000 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const { code } = request.params as { code: string };
|
||||||
|
const { ledIndex } = request.body as { ledIndex: number | null };
|
||||||
|
await service.assignLed(code, ledIndex);
|
||||||
|
return reply.code(204).send();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
|
||||||
|
/** Unauthenticated liveness/readiness probes. */
|
||||||
|
export default async function healthRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.get("/healthz", async () => ({ status: "ok" }));
|
||||||
|
|
||||||
|
app.get("/readyz", async (_request, reply) => {
|
||||||
|
try {
|
||||||
|
await app.db.execute(sql`select 1`);
|
||||||
|
} catch (err) {
|
||||||
|
app.log.error({ err }, "readiness check failed");
|
||||||
|
return reply.serviceUnavailable("database unreachable");
|
||||||
|
}
|
||||||
|
return { status: "ready", mqtt: app.mqtt.connected };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { SCOPES } from "../lib/scopes.js";
|
||||||
|
import { createPartsRepository } from "../repositories/parts.repository.js";
|
||||||
|
import { createPartsService } from "../services/parts.service.js";
|
||||||
|
|
||||||
|
const partInput = {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
mpn: { type: "string", minLength: 1, maxLength: 128 },
|
||||||
|
manufacturer: { type: "string", maxLength: 128 },
|
||||||
|
description: { type: "string", maxLength: 2000 },
|
||||||
|
categoryId: { type: "string", format: "uuid" },
|
||||||
|
package: { type: "string", maxLength: 64 },
|
||||||
|
minStock: { type: "integer", minimum: 0 },
|
||||||
|
lcscId: { type: "string", maxLength: 32 },
|
||||||
|
datasheetUrl: { type: "string", format: "uri", maxLength: 2048 },
|
||||||
|
photoUrl: { type: "string", format: "uri", maxLength: 2048 },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /v1/parts — the reference resource, wired through every layer:
|
||||||
|
* route → service (business rules) → repository (Drizzle) → db (DML role).
|
||||||
|
*/
|
||||||
|
export default async function partsRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
const service = createPartsService(createPartsRepository(app.db));
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
"/",
|
||||||
|
{
|
||||||
|
preValidation: app.requireScope(SCOPES.PARTS_READ),
|
||||||
|
schema: {
|
||||||
|
querystring: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
search: { type: "string", maxLength: 128 },
|
||||||
|
belowMinimum: { type: "boolean", default: false },
|
||||||
|
limit: { type: "integer", minimum: 1, maximum: 200, default: 50 },
|
||||||
|
offset: { type: "integer", minimum: 0, default: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request) => {
|
||||||
|
const { search, belowMinimum, limit, offset } = request.query as {
|
||||||
|
search?: string;
|
||||||
|
belowMinimum: boolean;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
};
|
||||||
|
const { items, total } = await service.list({ search, belowMinimum, limit, offset });
|
||||||
|
return { items, total, limit, offset };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
"/:id",
|
||||||
|
{
|
||||||
|
preValidation: app.requireScope(SCOPES.PARTS_READ),
|
||||||
|
schema: {
|
||||||
|
params: {
|
||||||
|
type: "object",
|
||||||
|
required: ["id"],
|
||||||
|
properties: { id: { type: "string", format: "uuid" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
return service.get(id);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
"/",
|
||||||
|
{
|
||||||
|
preValidation: app.requireScope(SCOPES.PARTS_WRITE),
|
||||||
|
schema: { body: { ...partInput, required: ["mpn"] } },
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const part = await service.create(request.body as { mpn: string });
|
||||||
|
return reply.code(201).send(part);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.patch(
|
||||||
|
"/:id",
|
||||||
|
{
|
||||||
|
preValidation: app.requireScope(SCOPES.PARTS_WRITE),
|
||||||
|
schema: {
|
||||||
|
params: {
|
||||||
|
type: "object",
|
||||||
|
required: ["id"],
|
||||||
|
properties: { id: { type: "string", format: "uuid" } },
|
||||||
|
},
|
||||||
|
body: partInput,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
return service.update(id, request.body as Record<string, unknown>);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import type { BinMode } from "../lib/mqtt-topics.js";
|
||||||
|
import { SCOPES } from "../lib/scopes.js";
|
||||||
|
import { createBoxesRepository } from "../repositories/boxes.repository.js";
|
||||||
|
import { createBoxesService } from "../services/boxes.service.js";
|
||||||
|
|
||||||
|
const boxNumberBody = {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ["boxNumber"],
|
||||||
|
properties: { boxNumber: { type: "integer", minimum: 1 } },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /v1/pick — pick-by-light control and live box state.
|
||||||
|
*
|
||||||
|
* Boxes idle on a rainbow animation; `POST /light` resolves the requested
|
||||||
|
* compartments to LED indices and lights them for `seconds`, then the box drops
|
||||||
|
* back to idle.
|
||||||
|
*/
|
||||||
|
export default async function pickRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
const boxes = createBoxesService(createBoxesRepository(app.db));
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
"/light",
|
||||||
|
{
|
||||||
|
preValidation: app.requireScope(SCOPES.LIGHT_CONTROL),
|
||||||
|
schema: {
|
||||||
|
body: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ["boxNumber", "bins"],
|
||||||
|
properties: {
|
||||||
|
boxNumber: { type: "integer", minimum: 1 },
|
||||||
|
bins: {
|
||||||
|
type: "array",
|
||||||
|
minItems: 1,
|
||||||
|
maxItems: 64,
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ["cell", "mode"],
|
||||||
|
properties: {
|
||||||
|
cell: { type: "string", minLength: 1, maxLength: 8 },
|
||||||
|
mode: { type: "string", enum: ["take", "put"] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
seconds: { type: "integer", minimum: 0, maximum: 600, default: 30 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const { boxNumber, bins, seconds } = request.body as {
|
||||||
|
boxNumber: number;
|
||||||
|
bins: Array<{ cell: string; mode: BinMode }>;
|
||||||
|
seconds: number;
|
||||||
|
};
|
||||||
|
const targets = await boxes.resolvePickTargets(boxNumber, bins);
|
||||||
|
await app.pickByLight.pick(boxNumber, targets, { seconds });
|
||||||
|
return reply.code(202).send({ boxNumber, targets, seconds });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
"/idle",
|
||||||
|
{ preValidation: app.requireScope(SCOPES.LIGHT_CONTROL), schema: { body: boxNumberBody } },
|
||||||
|
async (request, reply) => {
|
||||||
|
const { boxNumber } = request.body as { boxNumber: number };
|
||||||
|
await app.pickByLight.idle(boxNumber);
|
||||||
|
return reply.code(202).send({ boxNumber, effect: "idle" });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
"/off",
|
||||||
|
{ preValidation: app.requireScope(SCOPES.LIGHT_CONTROL), schema: { body: boxNumberBody } },
|
||||||
|
async (request, reply) => {
|
||||||
|
const { boxNumber } = request.body as { boxNumber: number };
|
||||||
|
await app.pickByLight.off(boxNumber);
|
||||||
|
return reply.code(202).send({ boxNumber, effect: "off" });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get("/boxes", { preValidation: app.requireScope(SCOPES.PARTS_READ) }, async () => ({
|
||||||
|
boxes: app.pickByLight.listBoxStates(),
|
||||||
|
controllers: app.pickByLight.listControllerStates(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
"/boxes/:number",
|
||||||
|
{
|
||||||
|
preValidation: app.requireScope(SCOPES.PARTS_READ),
|
||||||
|
schema: {
|
||||||
|
params: {
|
||||||
|
type: "object",
|
||||||
|
required: ["number"],
|
||||||
|
properties: { number: { type: "integer", minimum: 1 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const { number } = request.params as { number: number };
|
||||||
|
const state = app.pickByLight.boxState(number);
|
||||||
|
if (!state) return reply.notFound(`No state known for box ${number}`);
|
||||||
|
return state;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { ConflictError, NotFoundError, ValidationError } from "../lib/errors.js";
|
||||||
|
import { ledIndexFor } from "../lib/led-map.js";
|
||||||
|
import type { BinMode, BinTarget, BoxConfig } from "../lib/mqtt-topics.js";
|
||||||
|
import type {
|
||||||
|
Box,
|
||||||
|
BoxWithLocations,
|
||||||
|
BoxesRepository,
|
||||||
|
Controller,
|
||||||
|
} from "../repositories/boxes.repository.js";
|
||||||
|
|
||||||
|
export interface UpsertControllerInput {
|
||||||
|
espId: string;
|
||||||
|
name: string;
|
||||||
|
ledCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpsertBoxInput {
|
||||||
|
number: number;
|
||||||
|
name: string;
|
||||||
|
controllerEspId?: string | null;
|
||||||
|
ledOffset?: number;
|
||||||
|
ledCount?: number;
|
||||||
|
columns?: number;
|
||||||
|
rows?: number;
|
||||||
|
wiring?: "progressive" | "serpentine";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function boxConfigOf(box: Box, controller: Controller | undefined): BoxConfig {
|
||||||
|
return {
|
||||||
|
boxNumber: box.number,
|
||||||
|
controllerEspId: controller?.espId ?? null,
|
||||||
|
ledOffset: box.ledOffset,
|
||||||
|
ledCount: box.ledCount,
|
||||||
|
columns: box.columns,
|
||||||
|
rows: box.rows,
|
||||||
|
wiring: box.wiring,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBoxesService(repo: BoxesRepository) {
|
||||||
|
/** Ensure [offset, offset+count) fits the controller and no sibling overlaps. */
|
||||||
|
async function assertLedSliceFree(
|
||||||
|
controller: Controller,
|
||||||
|
box: { number: number; ledOffset: number; ledCount: number },
|
||||||
|
): Promise<void> {
|
||||||
|
const end = box.ledOffset + box.ledCount;
|
||||||
|
if (box.ledOffset < 0 || box.ledCount <= 0) {
|
||||||
|
throw new ValidationError("ledOffset must be >= 0 and ledCount > 0");
|
||||||
|
}
|
||||||
|
if (end > controller.ledCount) {
|
||||||
|
throw new ValidationError(
|
||||||
|
`box needs LEDs [${box.ledOffset}, ${end}) but controller ${controller.espId} only has ${controller.ledCount}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const sibling of await repo.boxesForController(controller.id)) {
|
||||||
|
if (sibling.number === box.number) continue;
|
||||||
|
const sEnd = sibling.ledOffset + sibling.ledCount;
|
||||||
|
if (box.ledOffset < sEnd && sibling.ledOffset < end) {
|
||||||
|
throw new ConflictError(
|
||||||
|
`LED range overlaps box ${sibling.number} ([${sibling.ledOffset}, ${sEnd}))`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
listControllers: () => repo.listControllers(),
|
||||||
|
listBoxes: () => repo.listBoxes(),
|
||||||
|
|
||||||
|
async getBox(number: number): Promise<BoxWithLocations> {
|
||||||
|
const box = await repo.findBoxByNumber(number);
|
||||||
|
if (!box) throw new NotFoundError("box", String(number));
|
||||||
|
return box;
|
||||||
|
},
|
||||||
|
|
||||||
|
async upsertController(input: UpsertControllerInput): Promise<Controller> {
|
||||||
|
const existing = await repo.findControllerByEspId(input.espId);
|
||||||
|
if (existing) {
|
||||||
|
const updated = await repo.updateController(existing.id, {
|
||||||
|
name: input.name,
|
||||||
|
ledCount: input.ledCount,
|
||||||
|
});
|
||||||
|
return updated ?? existing;
|
||||||
|
}
|
||||||
|
return repo.createController(input);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Create or update a box by its number; returns the box + its BoxConfig. */
|
||||||
|
async upsertBox(input: UpsertBoxInput): Promise<{ box: Box; config: BoxConfig }> {
|
||||||
|
const current = await repo.findBoxByNumber(input.number);
|
||||||
|
|
||||||
|
let controller: Controller | undefined;
|
||||||
|
if (input.controllerEspId) {
|
||||||
|
controller = await repo.findControllerByEspId(input.controllerEspId);
|
||||||
|
if (!controller) throw new NotFoundError("controller", input.controllerEspId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged = {
|
||||||
|
number: input.number,
|
||||||
|
name: input.name,
|
||||||
|
controllerId:
|
||||||
|
controller?.id ??
|
||||||
|
(input.controllerEspId === null ? null : (current?.controllerId ?? null)),
|
||||||
|
ledOffset: input.ledOffset ?? current?.ledOffset ?? 0,
|
||||||
|
ledCount: input.ledCount ?? current?.ledCount ?? 40,
|
||||||
|
columns: input.columns ?? current?.columns ?? 8,
|
||||||
|
rows: input.rows ?? current?.rows ?? 5,
|
||||||
|
wiring: input.wiring ?? current?.wiring ?? "progressive",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (controller) {
|
||||||
|
await assertLedSliceFree(controller, merged);
|
||||||
|
}
|
||||||
|
|
||||||
|
const box = current
|
||||||
|
? await repo.updateBox(input.number, merged)
|
||||||
|
: await repo.createBox(merged);
|
||||||
|
if (!box) throw new NotFoundError("box", String(input.number));
|
||||||
|
|
||||||
|
return { box, config: boxConfigOf(box, controller) };
|
||||||
|
},
|
||||||
|
|
||||||
|
async assignLed(locationCode: string, ledIndex: number | null): Promise<void> {
|
||||||
|
const updated = await repo.setLocationLedIndex(locationCode, ledIndex);
|
||||||
|
if (!updated) throw new NotFoundError("location", locationCode);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Resolve requested compartments to BinTargets (cell + mode + LED index). */
|
||||||
|
async resolvePickTargets(
|
||||||
|
boxNumber: number,
|
||||||
|
requested: Array<{ cell: string; mode: BinMode }>,
|
||||||
|
): Promise<BinTarget[]> {
|
||||||
|
const box = await repo.findBoxByNumber(boxNumber);
|
||||||
|
if (!box) throw new NotFoundError("box", String(boxNumber));
|
||||||
|
|
||||||
|
const grid = { columns: box.columns, rows: box.rows, wiring: box.wiring };
|
||||||
|
const byCell = new Map(box.locations.map((l) => [`${l.column}${l.row}`.toUpperCase(), l]));
|
||||||
|
|
||||||
|
return requested.map((r) => {
|
||||||
|
const loc = byCell.get(r.cell.toUpperCase());
|
||||||
|
if (loc) return { cell: r.cell, mode: r.mode, ledIndex: ledIndexFor(loc, grid) };
|
||||||
|
|
||||||
|
// No registered compartment — fall back to the grid formula from the code.
|
||||||
|
const m = r.cell.match(/^([A-Za-z]+)(\d+)$/);
|
||||||
|
if (!m) throw new ValidationError(`unrecognised cell "${r.cell}"`);
|
||||||
|
return {
|
||||||
|
cell: r.cell,
|
||||||
|
mode: r.mode,
|
||||||
|
ledIndex: ledIndexFor(
|
||||||
|
{ column: m[1]!, row: Number.parseInt(m[2]!, 10), ledIndex: null },
|
||||||
|
grid,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async boxConfig(number: number): Promise<BoxConfig> {
|
||||||
|
const box = await repo.findBoxByNumber(number);
|
||||||
|
if (!box) throw new NotFoundError("box", String(number));
|
||||||
|
const controller = box.controllerId
|
||||||
|
? await repo.findControllerById(box.controllerId)
|
||||||
|
: undefined;
|
||||||
|
return boxConfigOf(box, controller);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BoxesService = ReturnType<typeof createBoxesService>;
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { ConflictError, NotFoundError } from "../lib/errors.js";
|
||||||
|
import type {
|
||||||
|
ListPartsParams,
|
||||||
|
NewPart,
|
||||||
|
Part,
|
||||||
|
PartsRepository,
|
||||||
|
} from "../repositories/parts.repository.js";
|
||||||
|
|
||||||
|
export interface CreatePartInput {
|
||||||
|
mpn: string;
|
||||||
|
manufacturer?: string;
|
||||||
|
description?: string;
|
||||||
|
categoryId?: string;
|
||||||
|
package?: string;
|
||||||
|
minStock?: number;
|
||||||
|
lcscId?: string;
|
||||||
|
datasheetUrl?: string;
|
||||||
|
photoUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdatePartInput = Partial<CreatePartInput>;
|
||||||
|
|
||||||
|
export function createPartsService(repo: PartsRepository) {
|
||||||
|
return {
|
||||||
|
list(params: ListPartsParams) {
|
||||||
|
return repo.list(params);
|
||||||
|
},
|
||||||
|
|
||||||
|
async get(id: string): Promise<Part> {
|
||||||
|
const part = await repo.findById(id);
|
||||||
|
if (!part) throw new NotFoundError("part", id);
|
||||||
|
return part;
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(input: CreatePartInput): Promise<Part> {
|
||||||
|
if (input.lcscId) {
|
||||||
|
const existing = await repo.findByLcscId(input.lcscId);
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError(`A part with LCSC id ${input.lcscId} already exists`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const data: NewPart = {
|
||||||
|
mpn: input.mpn,
|
||||||
|
manufacturer: input.manufacturer,
|
||||||
|
description: input.description ?? "",
|
||||||
|
categoryId: input.categoryId,
|
||||||
|
package: input.package,
|
||||||
|
minStock: input.minStock ?? 0,
|
||||||
|
lcscId: input.lcscId,
|
||||||
|
datasheetUrl: input.datasheetUrl,
|
||||||
|
photoUrl: input.photoUrl,
|
||||||
|
};
|
||||||
|
return repo.create(data);
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(id: string, patch: UpdatePartInput): Promise<Part> {
|
||||||
|
const updated = await repo.update(id, patch);
|
||||||
|
if (!updated) throw new NotFoundError("part", id);
|
||||||
|
return updated;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PartsService = ReturnType<typeof createPartsService>;
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { ConflictError, ValidationError } from "../src/lib/errors.js";
|
||||||
|
import type { Box, BoxesRepository, Controller } from "../src/repositories/boxes.repository.js";
|
||||||
|
import { createBoxesService } from "../src/services/boxes.service.js";
|
||||||
|
|
||||||
|
const controller: Controller = {
|
||||||
|
id: "c1",
|
||||||
|
espId: "esp-a",
|
||||||
|
name: "Wall",
|
||||||
|
ledCount: 160,
|
||||||
|
online: true,
|
||||||
|
lastSeenAt: null,
|
||||||
|
firmware: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
function box(over: Partial<Box>): Box {
|
||||||
|
return {
|
||||||
|
id: "b?",
|
||||||
|
number: 1,
|
||||||
|
name: "Box",
|
||||||
|
controllerId: "c1",
|
||||||
|
ledOffset: 0,
|
||||||
|
ledCount: 40,
|
||||||
|
columns: 8,
|
||||||
|
rows: 5,
|
||||||
|
wiring: "progressive",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function repo(over: Partial<BoxesRepository> = {}): BoxesRepository {
|
||||||
|
return {
|
||||||
|
listControllers: vi.fn(),
|
||||||
|
findControllerById: vi.fn().mockResolvedValue(controller),
|
||||||
|
findControllerByEspId: vi.fn().mockResolvedValue(controller),
|
||||||
|
createController: vi.fn(),
|
||||||
|
updateController: vi.fn(),
|
||||||
|
upsertControllerByEspId: vi.fn(),
|
||||||
|
listBoxes: vi.fn(),
|
||||||
|
boxesForController: vi.fn().mockResolvedValue([]),
|
||||||
|
findBoxByNumber: vi.fn().mockResolvedValue(undefined),
|
||||||
|
createBox: vi.fn((d) => Promise.resolve(box(d))),
|
||||||
|
updateBox: vi.fn(),
|
||||||
|
setLocationLedIndex: vi.fn(),
|
||||||
|
...over,
|
||||||
|
} as unknown as BoxesRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("boxesService.upsertBox — LED slice validation", () => {
|
||||||
|
it("rejects a slice that runs past the controller's chain", async () => {
|
||||||
|
const svc = createBoxesService(repo());
|
||||||
|
await expect(
|
||||||
|
svc.upsertBox({
|
||||||
|
number: 2,
|
||||||
|
name: "B2",
|
||||||
|
controllerEspId: "esp-a",
|
||||||
|
ledOffset: 140,
|
||||||
|
ledCount: 40,
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(ValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a slice that overlaps a sibling box", async () => {
|
||||||
|
const svc = createBoxesService(
|
||||||
|
repo({
|
||||||
|
boxesForController: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([box({ number: 1, ledOffset: 0, ledCount: 40 })]),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
svc.upsertBox({
|
||||||
|
number: 2,
|
||||||
|
name: "B2",
|
||||||
|
controllerEspId: "esp-a",
|
||||||
|
ledOffset: 20,
|
||||||
|
ledCount: 40,
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(ConflictError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a non-overlapping slice and returns its config", async () => {
|
||||||
|
const svc = createBoxesService(
|
||||||
|
repo({
|
||||||
|
boxesForController: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([box({ number: 1, ledOffset: 0, ledCount: 40 })]),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { config } = await svc.upsertBox({
|
||||||
|
number: 2,
|
||||||
|
name: "B2",
|
||||||
|
controllerEspId: "esp-a",
|
||||||
|
ledOffset: 40,
|
||||||
|
ledCount: 40,
|
||||||
|
wiring: "serpentine",
|
||||||
|
});
|
||||||
|
expect(config).toMatchObject({
|
||||||
|
boxNumber: 2,
|
||||||
|
controllerEspId: "esp-a",
|
||||||
|
ledOffset: 40,
|
||||||
|
wiring: "serpentine",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("boxesService.resolvePickTargets", () => {
|
||||||
|
it("uses a registered compartment's explicit LED index", async () => {
|
||||||
|
const b = {
|
||||||
|
...box({ number: 3, columns: 8, rows: 5, wiring: "progressive" }),
|
||||||
|
locations: [
|
||||||
|
{
|
||||||
|
id: "l1",
|
||||||
|
boxId: "b?",
|
||||||
|
code: "K3·D4",
|
||||||
|
column: "D",
|
||||||
|
row: 4,
|
||||||
|
ledIndex: 99,
|
||||||
|
description: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const svc = createBoxesService(repo({ findBoxByNumber: vi.fn().mockResolvedValue(b) }));
|
||||||
|
const targets = await svc.resolvePickTargets(3, [{ cell: "D4", mode: "take" }]);
|
||||||
|
expect(targets).toEqual([{ cell: "D4", mode: "take", ledIndex: 99 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the grid formula for an unregistered cell", async () => {
|
||||||
|
const b = { ...box({ number: 3, columns: 8, rows: 5, wiring: "progressive" }), locations: [] };
|
||||||
|
const svc = createBoxesService(repo({ findBoxByNumber: vi.fn().mockResolvedValue(b) }));
|
||||||
|
const targets = await svc.resolvePickTargets(3, [{ cell: "D4", mode: "put" }]);
|
||||||
|
expect(targets).toEqual([{ cell: "D4", mode: "put", ledIndex: 27 }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { columnIndex, gridLedIndex, ledIndexFor } from "../src/lib/led-map.js";
|
||||||
|
|
||||||
|
describe("led-map", () => {
|
||||||
|
it("maps column letters to indices", () => {
|
||||||
|
expect(columnIndex("A")).toBe(0);
|
||||||
|
expect(columnIndex("d")).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("progressive wiring is row-major", () => {
|
||||||
|
const grid = { columns: 8, rows: 5, wiring: "progressive" as const };
|
||||||
|
expect(gridLedIndex("A", 1, grid)).toBe(0);
|
||||||
|
expect(gridLedIndex("D", 1, grid)).toBe(3);
|
||||||
|
expect(gridLedIndex("A", 2, grid)).toBe(8);
|
||||||
|
expect(gridLedIndex("D", 4, grid)).toBe(27);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serpentine wiring reverses every other row", () => {
|
||||||
|
const grid = { columns: 8, rows: 5, wiring: "serpentine" as const };
|
||||||
|
expect(gridLedIndex("A", 1, grid)).toBe(0); // row 1: L→R
|
||||||
|
expect(gridLedIndex("A", 2, grid)).toBe(15); // row 2: R→L, so col A is last
|
||||||
|
expect(gridLedIndex("H", 2, grid)).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an explicit ledIndex overrides the grid", () => {
|
||||||
|
const grid = { columns: 8, rows: 5, wiring: "progressive" as const };
|
||||||
|
expect(ledIndexFor({ column: "A", row: 1, ledIndex: 99 }, grid)).toBe(99);
|
||||||
|
expect(ledIndexFor({ column: "A", row: 1, ledIndex: null }, grid)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
boxNumberFromTopic,
|
||||||
|
idleCommand,
|
||||||
|
offCommand,
|
||||||
|
pickCommand,
|
||||||
|
topics,
|
||||||
|
} from "../src/lib/mqtt-topics.js";
|
||||||
|
|
||||||
|
describe("mqtt topic builders", () => {
|
||||||
|
const t = topics("findr");
|
||||||
|
|
||||||
|
it("builds the command and state topics for a box", () => {
|
||||||
|
expect(t.boxConfig(2)).toBe("findr/box/2/config");
|
||||||
|
expect(t.cmdLight(2)).toBe("findr/box/2/cmd/light");
|
||||||
|
expect(t.stateLight(2)).toBe("findr/box/2/state/light");
|
||||||
|
expect(t.ctrlOnline("abc")).toBe("findr/controller/abc/state/online");
|
||||||
|
expect(t.allBoxState()).toBe("findr/box/+/state/+");
|
||||||
|
expect(t.allControllerState()).toBe("findr/controller/+/state/+");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts the box number from a topic", () => {
|
||||||
|
expect(boxNumberFromTopic("findr/box/7/state/light")).toBe(7);
|
||||||
|
expect(boxNumberFromTopic("findr/box/x/state/light")).toBeNull();
|
||||||
|
expect(boxNumberFromTopic("other/box/7/state/light")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects a custom prefix", () => {
|
||||||
|
const p = topics("lab");
|
||||||
|
expect(p.cmdLight(3)).toBe("lab/box/3/cmd/light");
|
||||||
|
expect(boxNumberFromTopic("lab/box/3/evt/button", "lab")).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("light command builders", () => {
|
||||||
|
it("pick lights exactly the given bins", () => {
|
||||||
|
const cmd = pickCommand([{ cell: "D4", ledIndex: 27, mode: "take" }], {
|
||||||
|
seconds: 30,
|
||||||
|
requestId: "r1",
|
||||||
|
});
|
||||||
|
expect(cmd).toEqual({
|
||||||
|
effect: "pick",
|
||||||
|
bins: [{ cell: "D4", ledIndex: 27, mode: "take" }],
|
||||||
|
seconds: 30,
|
||||||
|
requestId: "r1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("idle and off carry just the effect", () => {
|
||||||
|
expect(idleCommand().effect).toBe("idle");
|
||||||
|
expect(offCommand().effect).toBe("off");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { ConflictError, NotFoundError } from "../src/lib/errors.js";
|
||||||
|
import type { Part, PartsRepository } from "../src/repositories/parts.repository.js";
|
||||||
|
import { createPartsService } from "../src/services/parts.service.js";
|
||||||
|
|
||||||
|
function fakePart(over: Partial<Part> = {}): Part {
|
||||||
|
return {
|
||||||
|
id: "00000000-0000-0000-0000-000000000001",
|
||||||
|
mpn: "ESP32-WROOM-32E",
|
||||||
|
manufacturer: "Espressif",
|
||||||
|
description: "",
|
||||||
|
categoryId: null,
|
||||||
|
package: null,
|
||||||
|
minStock: 5,
|
||||||
|
lcscId: null,
|
||||||
|
datasheetUrl: null,
|
||||||
|
photoUrl: null,
|
||||||
|
parameters: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function repoStub(over: Partial<PartsRepository> = {}): PartsRepository {
|
||||||
|
return {
|
||||||
|
list: vi.fn(),
|
||||||
|
findById: vi.fn(),
|
||||||
|
findByLcscId: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
...over,
|
||||||
|
} as unknown as PartsRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("partsService.get", () => {
|
||||||
|
it("returns the part when it exists", async () => {
|
||||||
|
const part = fakePart();
|
||||||
|
const service = createPartsService(repoStub({ findById: vi.fn().mockResolvedValue(part) }));
|
||||||
|
await expect(service.get(part.id)).resolves.toBe(part);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws NotFoundError when missing", async () => {
|
||||||
|
const service = createPartsService(
|
||||||
|
repoStub({ findById: vi.fn().mockResolvedValue(undefined) }),
|
||||||
|
);
|
||||||
|
await expect(service.get("missing")).rejects.toBeInstanceOf(NotFoundError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("partsService.create", () => {
|
||||||
|
it("rejects a duplicate LCSC id", async () => {
|
||||||
|
const service = createPartsService(
|
||||||
|
repoStub({ findByLcscId: vi.fn().mockResolvedValue(fakePart()) }),
|
||||||
|
);
|
||||||
|
await expect(service.create({ mpn: "X", lcscId: "C2913196" })).rejects.toBeInstanceOf(
|
||||||
|
ConflictError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates when the LCSC id is free", async () => {
|
||||||
|
const created = fakePart({ mpn: "X" });
|
||||||
|
const create = vi.fn().mockResolvedValue(created);
|
||||||
|
const service = createPartsService(
|
||||||
|
repoStub({ findByLcscId: vi.fn().mockResolvedValue(undefined), create }),
|
||||||
|
);
|
||||||
|
await expect(service.create({ mpn: "X", lcscId: "C2913196" })).resolves.toBe(created);
|
||||||
|
expect(create).toHaveBeenCalledWith(expect.objectContaining({ mpn: "X", lcscId: "C2913196" }));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"types": ["node"],
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": ".",
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src", "drizzle.config.ts", "tsup.config.ts", "test"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from "tsup";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
entry: ["src/index.ts", "src/db/migrate.ts"],
|
||||||
|
format: ["esm"],
|
||||||
|
target: "node22",
|
||||||
|
platform: "node",
|
||||||
|
outDir: "dist",
|
||||||
|
clean: true,
|
||||||
|
sourcemap: true,
|
||||||
|
// Keep node_modules external; only our own code is bundled.
|
||||||
|
skipNodeModulesBundle: true,
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: "node",
|
||||||
|
include: ["test/**/*.test.ts"],
|
||||||
|
clearMocks: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user