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:
2026-09-03 01:07:35 +02:00
commit cadc280761
45 changed files with 9723 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
node_modules
dist
.git
.env
.env.*
!.env.example
coverage
*.md
compose.yml
.github
+43
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
node_modules/
dist/
*.log
.env
.env.*
!.env.example
coverage/
.DS_Store
+33
View File
@@ -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"]
+138
View File
@@ -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
View File
@@ -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
View File
@@ -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:
+43
View File
@@ -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
View File
@@ -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/#`.
+21
View File
@@ -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,
});
+102
View File
@@ -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");
+721
View File
@@ -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": {}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1788384198271,
"tag": "0000_init",
"breakpoints": true
}
]
}
+6193
View File
File diff suppressed because it is too large Load Diff
+45
View File
@@ -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
View File
@@ -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;
}
+76
View File
@@ -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;
+29
View File
@@ -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 };
+15
View File
@@ -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);
});
+19
View File
@@ -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();
}
}
+189
View File
@@ -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] }),
}));
+25
View File
@@ -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);
});
+34
View File
@@ -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");
}
}
+40
View File
@@ -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);
}
+136
View File
@@ -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 };
}
+21
View File
@@ -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];
+112
View File
@@ -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: [] },
);
+30
View File
@@ -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" },
);
+116
View File
@@ -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" },
);
+118
View File
@@ -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"] },
);
+104
View File
@@ -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>;
+83
View File
@@ -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>;
+172
View File
@@ -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();
},
);
}
+17
View File
@@ -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 };
});
}
+105
View File
@@ -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>);
},
);
}
+110
View File
@@ -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;
},
);
}
+168
View File
@@ -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>;
+64
View File
@@ -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>;
+140
View File
@@ -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 }]);
});
});
+30
View File
@@ -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);
});
});
+53
View File
@@ -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");
});
});
+70
View File
@@ -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" }));
});
});
+23
View File
@@ -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"]
}
+13
View File
@@ -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,
});
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["test/**/*.test.ts"],
clearMocks: true,
},
});