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