Compare commits
No commits in common. "main" and "feat/admin-tech-steps" have entirely different histories.
main
...
feat/admin
406 changed files with 4026 additions and 25764 deletions
30
.env.example
30
.env.example
|
|
@ -15,42 +15,26 @@ JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
||||||
# (docker-compose.yml), serving both the API and the built frontend.
|
# (docker-compose.yml), serving both the API and the built frontend.
|
||||||
# APP_PORT=3000
|
# APP_PORT=3000
|
||||||
|
|
||||||
# Optional — tag of the prebuilt images the release CI pushes to the Forgejo
|
# --- Admin application (apps/admin-web + the /admin/* API surface) ---------
|
||||||
# container registry (git.dev.kyuno.fr/kyuno/batchcooking-{app,intent-service,
|
|
||||||
# llm-worker}). Used by `docker compose pull` and a pull-based Portainer
|
|
||||||
# stack; leave it `latest` or pin a release tag (e.g. v0.2.4). Ignored when
|
|
||||||
# building locally instead (`docker compose build`, or `up` with no image
|
|
||||||
# present) — see docker-compose.yml's `image:`/`build:` comment.
|
|
||||||
# IMAGE_TAG=latest
|
|
||||||
|
|
||||||
# --- Admin surface (apps/web's /admin/* routes + the /admin/* API) --------
|
|
||||||
# All optional: an instance that doesn't run the admin app needs none of
|
# All optional: an instance that doesn't run the admin app needs none of
|
||||||
# these. `requireAdmin` fails closed when ADMIN_JWT_SECRET is unset, so
|
# these. `requireAdmin` fails closed when ADMIN_JWT_SECRET is unset, so
|
||||||
# leaving it out simply disables every /admin/* route. The admin UI is
|
# leaving it out simply disables every /admin/* route.
|
||||||
# served by the same app/container as the rest of the frontend — no
|
|
||||||
# separate origin, so no CORS entry of its own.
|
|
||||||
#
|
#
|
||||||
# Secret for the admin session JWT — MUST be different from JWT_SECRET so an
|
# Secret for the admin session JWT — MUST be different from JWT_SECRET so an
|
||||||
# end-user token can never be replayed against /admin/*. Generate your own
|
# end-user token can never be replayed against /admin/*. Generate your own
|
||||||
# the same way as JWT_SECRET above.
|
# the same way as JWT_SECRET above.
|
||||||
# ADMIN_JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
# ADMIN_JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
||||||
|
# Origin apps/admin-web is served from, added to the CORS allow-list.
|
||||||
|
# ADMIN_CORS_ORIGIN=http://localhost:5174
|
||||||
|
# Host port for the Docker `admin-web` service (static nginx serving the
|
||||||
|
# built admin frontend).
|
||||||
|
# ADMIN_WEB_PORT=3001
|
||||||
# Optional — read only by `src/scripts/create-admin.ts` when its --email /
|
# Optional — read only by `src/scripts/create-admin.ts` when its --email /
|
||||||
# --password / --name flags are omitted (e.g. to bootstrap the first admin
|
# --password / --name flags are omitted (e.g. to bootstrap the first admin
|
||||||
# from inside the container). Never read by the running server.
|
# from inside the container). Never read by the running server.
|
||||||
# ADMIN_INITIAL_EMAIL=ops@example.com
|
# ADMIN_INITIAL_EMAIL=ops@example.com
|
||||||
# ADMIN_INITIAL_PASSWORD=changeme-at-least-8-chars
|
# ADMIN_INITIAL_PASSWORD=changeme-at-least-8-chars
|
||||||
# ADMIN_INITIAL_NAME=Ops
|
# ADMIN_INITIAL_NAME=Ops
|
||||||
#
|
|
||||||
# Optional — lets an admin open a Forgejo ticket from a contact message
|
|
||||||
# (apps/web's /admin/messages, issue #81). All three needed together: the
|
|
||||||
# button stays hidden and the endpoint answers 503 unless all are set to a
|
|
||||||
# non-empty value. docker-compose.yml forwards these into the "app"
|
|
||||||
# container, so setting them here is enough (a blank value counts as
|
|
||||||
# unset). FORGEJO_TOKEN is a personal access token with the write:issue
|
|
||||||
# scope — generate it in Forgejo, never commit the real value.
|
|
||||||
# FORGEJO_BASE_URL=https://git.example.com
|
|
||||||
# FORGEJO_TOKEN=changeme-a-forgejo-personal-access-token
|
|
||||||
# FORGEJO_REPO=owner/repo
|
|
||||||
|
|
||||||
# Optional — only set this to false if THIS deployment is served over
|
# Optional — only set this to false if THIS deployment is served over
|
||||||
# plain HTTP (no TLS in front of it). Left unset, the session cookie
|
# plain HTTP (no TLS in front of it). Left unset, the session cookie
|
||||||
|
|
|
||||||
247
.github/workflows/ci.yml
vendored
247
.github/workflows/ci.yml
vendored
|
|
@ -12,15 +12,6 @@ on:
|
||||||
push:
|
push:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
# Hostname `postgres` = the container the `test` job starts itself (see
|
|
||||||
# that job's "Start Postgres" step) — a plain `docker run`, not a
|
|
||||||
# `services:` block, for the same reason the intent-service below can't be
|
|
||||||
# one: this repo's Forgejo `act` runner does not keep a `services:`
|
|
||||||
# container as the *same* instance across a job's steps, so `migrate
|
|
||||||
# deploy` would land the schema in one Postgres and `pnpm --filter api
|
|
||||||
# test`'s `beforeAll` would then hit an empty one ("table … does not
|
|
||||||
# exist" right after "migrations successfully applied"). One long-lived
|
|
||||||
# container in the job, shared by every step, removes that failure mode.
|
|
||||||
DATABASE_URL: "postgresql://ci:ci@postgres:5432/batchcooking_ci?schema=public"
|
DATABASE_URL: "postgresql://ci:ci@postgres:5432/batchcooking_ci?schema=public"
|
||||||
# Test-only secret, never used outside CI — real deployments must set their own.
|
# Test-only secret, never used outside CI — real deployments must set their own.
|
||||||
JWT_SECRET: "ci-only-secret-not-used-anywhere-else-32chars+"
|
JWT_SECRET: "ci-only-secret-not-used-anywhere-else-32chars+"
|
||||||
|
|
@ -33,19 +24,11 @@ env:
|
||||||
# secret configured -> 401" path.
|
# secret configured -> 401" path.
|
||||||
ADMIN_JWT_SECRET: "ci-only-admin-secret-not-used-anywhere-else-32chars+"
|
ADMIN_JWT_SECRET: "ci-only-admin-secret-not-used-anywhere-else-32chars+"
|
||||||
# Shared between the `test` job's own uvicorn step (below) and apps/api's
|
# Shared between the `test` job's own uvicorn step (below) and apps/api's
|
||||||
# IntentServiceClient — that step runs `uvicorn` as a plain background
|
# IntentServiceClient — see the `test` job for why this can't be a
|
||||||
# process (not a `services:` block or even a container) because CI can
|
# `services:` container like postgres above (GitHub Actions can only pull
|
||||||
# only pull a published image, not build
|
# a published image, not build services/tech-step-intent-service/Dockerfile).
|
||||||
# services/tech-step-intent-service/Dockerfile from this checkout.
|
|
||||||
INTENT_SERVICE_BASE_URL: "http://localhost:8000"
|
INTENT_SERVICE_BASE_URL: "http://localhost:8000"
|
||||||
INTENT_SERVICE_SECRET: "ci-only-intent-secret-not-used-anywhere-else-32chars+"
|
INTENT_SERVICE_SECRET: "ci-only-intent-secret-not-used-anywhere-else-32chars+"
|
||||||
# Same reasoning — lets forgejo.service.test.ts / admin-contact.test.ts
|
|
||||||
# exercise the "configured" path (issue #81). `globalThis.fetch` is stubbed
|
|
||||||
# in those tests, so no real request ever leaves CI; these values only need
|
|
||||||
# to parse (a valid URL and an `owner/repo`).
|
|
||||||
FORGEJO_BASE_URL: "https://forgejo.ci.invalid"
|
|
||||||
FORGEJO_TOKEN: "ci-only-forgejo-token-not-used-anywhere-else"
|
|
||||||
FORGEJO_REPO: "ci/batchcooking"
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Five independent jobs, no needs: between them — each starts in parallel
|
# Five independent jobs, no needs: between them — each starts in parallel
|
||||||
|
|
@ -68,96 +51,22 @@ jobs:
|
||||||
|
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
# ~16-19 min of actual work (runs 117-124); the extra budget covers time
|
services:
|
||||||
# spent queued in "Wait for any earlier test job" below. Also a runaway
|
postgres:
|
||||||
# ceiling — Mocha has no `--exit` (`apps/api/.mocharc.json`), so one
|
image: postgres:16-alpine
|
||||||
# leaked handle would otherwise hang the job until the runner's global
|
env:
|
||||||
# timeout (a `test` job zombied ~1h once). If a hung job hits this
|
POSTGRES_USER: ci
|
||||||
# ceiling the runner removes its container, which unblocks the queue.
|
POSTGRES_PASSWORD: ci
|
||||||
timeout-minutes: 45
|
POSTGRES_DB: batchcooking_ci
|
||||||
|
options: >-
|
||||||
|
--health-cmd pg_isready
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 10
|
||||||
steps:
|
steps:
|
||||||
- name: Install curl and the docker CLI
|
- name: Install curl and dependencies
|
||||||
run: |
|
run: |
|
||||||
apt-get update && apt-get install -y curl docker.io
|
apt-get update && apt-get install -y curl
|
||||||
|
|
||||||
# Serialize `test` across every concurrent run. Two `test` jobs must
|
|
||||||
# never overlap: they share one `--name postgres` container on the
|
|
||||||
# runner's shared docker network (next step), so their `prisma migrate
|
|
||||||
# deploy` + `seedReferenceData` (root Mocha `beforeAll`) race —
|
|
||||||
# `Unique constraint failed on (key)` in `seedReferenceData`, "No
|
|
||||||
# pending migrations" when there should be some, `tech_step`/`diet` id
|
|
||||||
# shifts breaking unrelated assertions; and even past that they starve
|
|
||||||
# each other's RAM/CPU (each also trains a spaCy model) until
|
|
||||||
# `resetDatabase()` overruns Mocha's 10s hook timeout and cascades.
|
|
||||||
# Hit repeatedly (PRs #47, #53, #54), each needing an empty-commit
|
|
||||||
# re-trigger once the runner was idle.
|
|
||||||
#
|
|
||||||
# GitHub's `concurrency:` is the native fix but this Forgejo (9.0.3)
|
|
||||||
# predates Actions `concurrency` — it arrived in Forgejo v14 / Gitea
|
|
||||||
# 1.26 and is silently ignored here. A lock object (network, container,
|
|
||||||
# file) would leak: this runner (forgejo-runner v3.5.0) does NOT run a
|
|
||||||
# trailing `if: always()` step after an earlier step fails, so there is
|
|
||||||
# no reliable in-job release. Instead, key off the one thing the runner
|
|
||||||
# *does* always clean up — the job container itself (removed on pass,
|
|
||||||
# fail, and cancel alike): wait while any lower-numbered `test` job's
|
|
||||||
# container is still running. Lower task id goes first, so two jobs
|
|
||||||
# that start together can't deadlock; nothing to release.
|
|
||||||
- name: Wait for any earlier test job
|
|
||||||
run: |
|
|
||||||
my_id="$(printf '%s\n' "${JOB_CONTAINER_NAME:-}" | sed -n 's/.*TASK-\([0-9]\{1,\}\).*/\1/p')"
|
|
||||||
echo "this test job is task ${my_id:-<unknown, not serializing>}"
|
|
||||||
# ~25 min cap (75 * 20s): comfortably covers one earlier run
|
|
||||||
# (the realistic overlap is two runs); a rarer 3-way pile-up whose
|
|
||||||
# wait exceeds this just fails and gets an empty-commit re-run.
|
|
||||||
for attempt in $(seq 1 75); do
|
|
||||||
earlier="$(docker ps --format '{{.Names}}' 2>/dev/null \
|
|
||||||
| sed -n 's/.*TASK-\([0-9]\{1,\}\)_.*_JOB-test$/\1/p' \
|
|
||||||
| awk -v me="${my_id:-0}" 'NF && ($1 + 0) < (me + 0)')"
|
|
||||||
if [ -z "$earlier" ]; then
|
|
||||||
[ "$attempt" -gt 1 ] && echo "earlier test job(s) finished — proceeding"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "waiting for earlier test job(s): $(printf '%s ' $earlier)(attempt ${attempt})"
|
|
||||||
sleep 20
|
|
||||||
done
|
|
||||||
echo "still blocked by an earlier test job after ~25 min — giving up; a re-run should get through" >&2
|
|
||||||
exit 1
|
|
||||||
|
|
||||||
# Postgres as a plain background container instead of a `services:`
|
|
||||||
# block — see this workflow's top-level `env.DATABASE_URL` comment for
|
|
||||||
# why. Reachable as `postgres` from this job (the runner mounts the
|
|
||||||
# docker socket and runs jobs on a shared user-defined network, so a
|
|
||||||
# `--name postgres` sibling resolves by name); the network is
|
|
||||||
# discovered from the job container itself rather than hardcoded. The
|
|
||||||
# `docker rm -f postgres` below also clears a container left behind by
|
|
||||||
# the previous run (this runner has no reliable post-job cleanup), and
|
|
||||||
# the serialization step above means no other run is using it now.
|
|
||||||
- name: Start Postgres in the background
|
|
||||||
run: |
|
|
||||||
net="$(docker inspect "$(hostname)" \
|
|
||||||
--format '{{range $k, $_ := .NetworkSettings.Networks}}{{$k}} {{end}}' \
|
|
||||||
| awk '{print $1}')"
|
|
||||||
echo "job network: ${net:?could not determine the job container network}"
|
|
||||||
docker rm -f postgres >/dev/null 2>&1 || true
|
|
||||||
# Also drop any `postgres-<suffix>` container. A since-reverted
|
|
||||||
# attempt at per-run Postgres naming on this branch left some
|
|
||||||
# running on this persistent runner (their `if: always()` teardown
|
|
||||||
# never fired — this runner skips trailing steps after a failure),
|
|
||||||
# and nothing else removes them. A second idle Postgres here starves
|
|
||||||
# the runner's RAM and slows every query ~5x → `resetDatabase()`
|
|
||||||
# blows Mocha's 10s hook timeout. Nothing creates `postgres-*` any
|
|
||||||
# more, so this only ever clears that debris.
|
|
||||||
docker ps -aq --filter 'name=^postgres-' | xargs -r docker rm -f >/dev/null 2>&1 || true
|
|
||||||
docker run -d --name postgres --network "$net" \
|
|
||||||
-e POSTGRES_USER=ci -e POSTGRES_PASSWORD=ci -e POSTGRES_DB=batchcooking_ci \
|
|
||||||
postgres:16-alpine
|
|
||||||
for _ in $(seq 1 60); do
|
|
||||||
docker exec postgres pg_isready -U ci -d batchcooking_ci && exit 0
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
echo "Postgres did not become ready in time" >&2
|
|
||||||
docker logs postgres || true
|
|
||||||
exit 1
|
|
||||||
|
|
||||||
- uses: https://github.com/actions/checkout@v4
|
- uses: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
|
|
@ -173,14 +82,15 @@ jobs:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
|
|
||||||
# A `services:` block can only pull an already-published image — it
|
# `services:` (like the `postgres` container above) can only pull an
|
||||||
# can't build services/tech-step-intent-service/Dockerfile from this
|
# already-published image — it can't build
|
||||||
# checkout. Running `uvicorn` as a plain background step instead: it
|
# services/tech-step-intent-service/Dockerfile from this checkout.
|
||||||
# keeps running for the rest of this job (steps in one job share the
|
# Running `uvicorn` as a plain background step instead: it keeps
|
||||||
# same process tree), and `pnpm --filter api test` below needs a real
|
# running for the rest of this job (GitHub Actions steps in one job
|
||||||
# instance to talk to per this repo's "never mock an internal service"
|
# share the same runner process tree), and `pnpm --filter api test`
|
||||||
# test convention — same "real dependency, started by the job itself"
|
# below needs a real instance to talk to per this repo's "never mock
|
||||||
# posture as the Postgres step above, not a mock HTTP server.
|
# an internal service" test convention — same reasoning as the real
|
||||||
|
# `postgres` container just above, not a mock HTTP server.
|
||||||
- name: Install services/tech-step-intent-service
|
- name: Install services/tech-step-intent-service
|
||||||
working-directory: services/tech-step-intent-service
|
working-directory: services/tech-step-intent-service
|
||||||
run: uv sync --frozen
|
run: uv sync --frozen
|
||||||
|
|
@ -190,14 +100,11 @@ jobs:
|
||||||
uv run uvicorn intent_service.main:app --host 0.0.0.0 --port 8000 &
|
uv run uvicorn intent_service.main:app --host 0.0.0.0 --port 8000 &
|
||||||
# `/health` only returns 200 once this service has finished
|
# `/health` only returns 200 once this service has finished
|
||||||
# training itself from scratch (no model ever persisted to disk —
|
# training itself from scratch (no model ever persisted to disk —
|
||||||
# see its own README) — measured at ~3min combined (fr+en) against
|
# see its own README) — measured at ~540s (fr) / ~390s (en),
|
||||||
# the current ~74-technique corpus, down from ~930s once
|
# ~930s combined, against the current ~74-technique corpus (see
|
||||||
# `spacy.TextCatBOW.v3`'s `length` stopped being left at its
|
# docker-compose.yml's healthcheck for the same reasoning and why
|
||||||
# 262144 default (see `_TEXTCAT_MODEL_LENGTH` in
|
# this grew slightly from the original ~670s).
|
||||||
# `locale_pipeline.py`, and docker-compose.yml's healthcheck for
|
timeout 1200 bash -c 'until curl -sf http://localhost:8000/health > /dev/null; do sleep 2; done'
|
||||||
# the same reasoning). The timeout stays well above that: it's a
|
|
||||||
# ceiling for a slow/contended runner, not a target.
|
|
||||||
timeout 600 bash -c 'until curl -sf http://localhost:8000/health > /dev/null; do sleep 2; done'
|
|
||||||
|
|
||||||
- run: pnpm install --frozen-lockfile
|
- run: pnpm install --frozen-lockfile
|
||||||
- run: pnpm --filter api exec prisma migrate deploy
|
- run: pnpm --filter api exec prisma migrate deploy
|
||||||
|
|
@ -272,90 +179,10 @@ jobs:
|
||||||
# `component.devServer`), unlike `e2e` above which needs the real app
|
# `component.devServer`), unlike `e2e` above which needs the real app
|
||||||
# running first.
|
# running first.
|
||||||
- run: pnpm --filter web cy:run:component
|
- run: pnpm --filter web cy:run:component
|
||||||
|
# The admin app's own Cypress suite (`apps/admin-web`) — its own dev
|
||||||
# Builds and packages a signed, downloadable Android APK on every push to
|
# server on :5174, all `/admin/*` calls mocked via `cy.intercept`
|
||||||
# `main` — issue #64. `needs:` on every other job is what actually makes
|
# (no live backend needed), same as the `web` e2e run above.
|
||||||
# this "build, TEST, and package" rather than just "build and package in
|
- name: Run admin-web E2E tests
|
||||||
# parallel with tests that might still be failing": Forgejo/GitHub Actions
|
|
||||||
# has no native way for a job in a *different* workflow file to block on
|
|
||||||
# one here, so this has to live in this same file to get a real dependency
|
|
||||||
# rather than a same-push coincidence. `if:` (not a separate trigger) keeps
|
|
||||||
# every other job running on every branch as today — only this one is
|
|
||||||
# main-only.
|
|
||||||
#
|
|
||||||
# No dedicated Android test suite exists (zero native code beyond the
|
|
||||||
# Capacitor-generated shell) — "testée" is satisfied by gating on the 5
|
|
||||||
# existing jobs, not by inventing an instrumented test suite for this.
|
|
||||||
#
|
|
||||||
# Unverified on this runner as of writing: no Android build has ever run
|
|
||||||
# on this self-hosted Forgejo `act` runner (unlike lint/test/build/e2e,
|
|
||||||
# exercised for months). If SDK installation turns out too slow/heavy here,
|
|
||||||
# cache `~/.android/sdk`/`~/.gradle` (same idea as the Cypress binary cache
|
|
||||||
# in `e2e` above) rather than accepting a slow job on every push.
|
|
||||||
build-android:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [lint, test, intent-service-test, build, e2e]
|
|
||||||
if: github.ref == 'refs/heads/main'
|
|
||||||
steps:
|
|
||||||
- uses: https://github.com/actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
|
||||||
|
|
||||||
- uses: https://github.com/actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
cache: pnpm
|
|
||||||
|
|
||||||
- uses: https://github.com/actions/setup-java@v4
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
# Matches what Android Gradle Plugin 8.x (bundled with the
|
|
||||||
# Capacitor-generated project, see apps/web/android/build.gradle)
|
|
||||||
# expects — bump alongside AGP if that version ever moves.
|
|
||||||
java-version: "21"
|
|
||||||
|
|
||||||
- uses: https://github.com/android-actions/setup-android@v3
|
|
||||||
|
|
||||||
- run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
# Unlike the normal web build (served same-origin as the API), a
|
|
||||||
# packaged Capacitor build runs from the WebView's own local origin and
|
|
||||||
# needs an absolute, public HTTPS URL baked in at build time — see
|
|
||||||
# apps/web/.env.capacitor.example's own comment on this.
|
|
||||||
- name: Build le bundle web pour Capacitor
|
|
||||||
env:
|
env:
|
||||||
VITE_API_URL: ${{ secrets.MOBILE_API_URL }}
|
HOST: "0.0.0.0"
|
||||||
run: pnpm --filter web build:capacitor
|
run: pnpm --filter admin-web e2e
|
||||||
- run: pnpm --filter web exec cap sync android
|
|
||||||
|
|
||||||
# Le keystore de release est un secret généré une fois à la main (voir
|
|
||||||
# le README, section "Application mobile") — jamais régénéré ici, un
|
|
||||||
# nouveau keystore casserait la mise à jour de l'appli déjà installée
|
|
||||||
# (Android refuse une signature différente de celle déjà installée).
|
|
||||||
- name: Décode le keystore de release
|
|
||||||
env:
|
|
||||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
|
||||||
run: echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > apps/web/android/app/release.keystore
|
|
||||||
|
|
||||||
- name: Build l'APK signé
|
|
||||||
working-directory: apps/web/android
|
|
||||||
env:
|
|
||||||
ANDROID_KEYSTORE_PATH: release.keystore
|
|
||||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
|
||||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
|
||||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
|
||||||
# chmod défensif : le dev de ce repo se fait sous Windows, où le bit
|
|
||||||
# exécutable git de gradlew ne veut rien dire et peut être reperdu
|
|
||||||
# silencieusement par un futur commit — ne pas dépendre uniquement
|
|
||||||
# du mode suivi par git (voir aussi le fix de ce mode lui-même).
|
|
||||||
run: |
|
|
||||||
chmod +x ./gradlew
|
|
||||||
./gradlew assembleRelease
|
|
||||||
|
|
||||||
# v4+ exige l'API "Results" de GitHub, que ce runner Forgejo self-hosted
|
|
||||||
# ne supporte pas ("GHESNotSupportedError") — v3 utilise encore
|
|
||||||
# l'ancienne API Artifacts (compatible), seule version utilisable ici.
|
|
||||||
- uses: https://github.com/actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: batchcooking-release.apk
|
|
||||||
path: apps/web/android/app/build/outputs/apk/release/app-release.apk
|
|
||||||
|
|
|
||||||
239
.github/workflows/release.yml
vendored
239
.github/workflows/release.yml
vendored
|
|
@ -1,243 +1,44 @@
|
||||||
name: Release
|
name: Release
|
||||||
|
|
||||||
# On a `vX.Y.Z` tag: build the three service images and push them to this
|
# No image registry involved: Portainer is wired to this repo's Git remote
|
||||||
# Forgejo instance's built-in container registry (git.dev.kyuno.fr/<owner>/
|
# and builds/redeploys apps/api/Dockerfile itself. This workflow sanity-checks
|
||||||
# batchcooking-{app,intent-service,llm-worker}, tagged with the release tag
|
# that the image actually builds at the tagged commit, publishes a GitHub
|
||||||
# and `latest`), build+attach a signed Android APK, publish a Forgejo
|
# Release, then (best-effort) pings Portainer's deploy webhook so it doesn't
|
||||||
# Release, then (best-effort) ping Portainer's deploy webhook. Portainer can
|
# have to wait for its own polling interval.
|
||||||
# pull those prebuilt images (docker-compose.yml references them via
|
|
||||||
# ${IMAGE_TAG}) or keep building from the Git remote — `docker-compose.yml`
|
|
||||||
# keeps a `build:` fallback next to every `image:`.
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags: ["v*.*.*"]
|
tags: ["v*.*.*"]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
# Lets this workflow's GITHUB_TOKEN push to the Forgejo container registry
|
|
||||||
# (the `build-and-push-images` job below `docker login`s with it). If a
|
|
||||||
# future instance policy scopes the run token tighter, swap that login's
|
|
||||||
# password for a `write:package` PAT added as a repo secret.
|
|
||||||
packages: write
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Build the three service images at the tagged commit and push them to the
|
sanity-build:
|
||||||
# Forgejo container registry. A successful push also *is* the "does it
|
|
||||||
# still build" check the old `sanity-build` job did (which only covered
|
|
||||||
# apps/api). One sequential job, not a per-image matrix: three concurrent
|
|
||||||
# `docker build`s hammer this single persistent runner's Docker daemon
|
|
||||||
# (the contended resource per project memory `ci-forgejo-runner-constraints`)
|
|
||||||
# for no real gain on a release, which is rare and usually runs alone.
|
|
||||||
build-and-push-images:
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
# This runner's job containers don't come with docker preinstalled —
|
- uses: actions/checkout@v4
|
||||||
# same reason ci.yml's `test` job has to do this before its own
|
|
||||||
# `docker run` calls.
|
|
||||||
- name: Install docker
|
|
||||||
run: apt-get update && apt-get install -y docker.io
|
|
||||||
|
|
||||||
- uses: https://github.com/actions/checkout@v4
|
# Build only, no push, no registry — just confirms Portainer will be
|
||||||
|
# able to build this same Dockerfile successfully at this tag before
|
||||||
|
# anyone points it at this ref.
|
||||||
|
- run: docker build -f apps/api/Dockerfile .
|
||||||
|
|
||||||
- name: Build et push des 3 images vers le registre Forgejo
|
github-release:
|
||||||
env:
|
|
||||||
# The per-run token Forgejo Actions mints — has `packages: write`
|
|
||||||
# via this workflow's `permissions:` block. `docker login` accepts
|
|
||||||
# it as the password for the actor who pushed the tag.
|
|
||||||
REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
# $GITHUB_SERVER_URL is "https://git.dev.kyuno.fr" — the registry
|
|
||||||
# is that same host. $GITHUB_REPOSITORY is "<owner>/<repo>"; the
|
|
||||||
# registry namespace is the owner, lower-cased (Forgejo package
|
|
||||||
# names are case-insensitive but the CLI wants a stable form).
|
|
||||||
registry="${GITHUB_SERVER_URL#http*://}"
|
|
||||||
owner="$(printf '%s' "${GITHUB_REPOSITORY%%/*}" | tr '[:upper:]' '[:lower:]')"
|
|
||||||
tag="$GITHUB_REF_NAME"
|
|
||||||
echo "$REGISTRY_TOKEN" | docker login "$registry" -u "$GITHUB_ACTOR" --password-stdin
|
|
||||||
|
|
||||||
# Every Dockerfile's build context is the repo root (see
|
|
||||||
# docker-compose.yml's `build.context: .`), hence the trailing `.`.
|
|
||||||
build_push() {
|
|
||||||
image="$registry/$owner/$1"
|
|
||||||
echo "::group::$1"
|
|
||||||
docker build -f "$2" -t "$image:$tag" -t "$image:latest" .
|
|
||||||
docker push "$image:$tag"
|
|
||||||
docker push "$image:latest"
|
|
||||||
echo "::endgroup::"
|
|
||||||
}
|
|
||||||
build_push batchcooking-app apps/api/Dockerfile
|
|
||||||
build_push batchcooking-intent-service services/tech-step-intent-service/Dockerfile
|
|
||||||
build_push batchcooking-llm-worker services/tech-step-llm-worker/Dockerfile
|
|
||||||
|
|
||||||
# Builds the same signed APK as ci.yml's build-android job (issue #64),
|
|
||||||
# with one difference: the version baked into the APK is derived from the
|
|
||||||
# tag itself rather than left at build.gradle's default. Duplicated here
|
|
||||||
# rather than factored into a shared `workflow_call` reusable workflow —
|
|
||||||
# deliberate: reusable-workflow support on this self-hosted Forgejo/act
|
|
||||||
# runner is unverified, and this ~15-line recipe is proven working as-is
|
|
||||||
# in ci.yml; not worth risking a new class of runner-compatibility surprise
|
|
||||||
# (this repo has already hit several — see ci-forgejo-runner-constraints
|
|
||||||
# in project memory) to save a little duplication. Keep both copies in
|
|
||||||
# sync if the Android build steps ever change.
|
|
||||||
build-android-apk:
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: sanity-build
|
||||||
steps:
|
steps:
|
||||||
- uses: https://github.com/actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: softprops/action-gh-release@v2
|
||||||
|
|
||||||
- uses: https://github.com/actions/setup-node@v4
|
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
# Auto-generated from merged PRs since the previous tag — no
|
||||||
cache: pnpm
|
# changelog tooling to maintain, consistent with there being no
|
||||||
|
# versioning tooling elsewhere in the repo yet.
|
||||||
- uses: https://github.com/actions/setup-java@v4
|
generate_release_notes: true
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: "21"
|
|
||||||
|
|
||||||
- uses: https://github.com/android-actions/setup-android@v3
|
|
||||||
|
|
||||||
- run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Build le bundle web pour Capacitor
|
|
||||||
env:
|
|
||||||
VITE_API_URL: ${{ secrets.MOBILE_API_URL }}
|
|
||||||
run: pnpm --filter web build:capacitor
|
|
||||||
- run: pnpm --filter web exec cap sync android
|
|
||||||
|
|
||||||
- name: Décode le keystore de release
|
|
||||||
env:
|
|
||||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
|
||||||
run: echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > apps/web/android/app/release.keystore
|
|
||||||
|
|
||||||
- name: Dérive versionName/versionCode depuis le tag
|
|
||||||
# GITHUB_REF_NAME = "v1.4.2" pour un tag `v1.4.2` — versionName =
|
|
||||||
# "1.4.2" (sans le `v`), versionCode = majeur*10000 + mineur*100 +
|
|
||||||
# correctif (ex. 1.4.2 -> 10402), un entier strictement croissant à
|
|
||||||
# chaque release comme Android l'exige (jamais réutilisé, jamais
|
|
||||||
# décroissant) tant que chaque partie reste sous 100 (largement
|
|
||||||
# suffisant ici). Exposé aux steps suivants via $GITHUB_ENV plutôt
|
|
||||||
# que recalculé.
|
|
||||||
run: |
|
|
||||||
tag="${GITHUB_REF_NAME#v}"
|
|
||||||
major="$(echo "$tag" | cut -d. -f1)"
|
|
||||||
minor="$(echo "$tag" | cut -d. -f2)"
|
|
||||||
patch="$(echo "$tag" | cut -d. -f3)"
|
|
||||||
# Préfixe 10# : force une lecture en base 10, sinon bash lirait
|
|
||||||
# un composant à zéro non significatif (ex. "08") comme de
|
|
||||||
# l'octal invalide et échouerait.
|
|
||||||
code=$((10#$major * 10000 + 10#$minor * 100 + 10#$patch))
|
|
||||||
echo "Version dérivée du tag $GITHUB_REF_NAME : versionName=$tag versionCode=$code"
|
|
||||||
echo "ANDROID_VERSION_NAME=$tag" >> "$GITHUB_ENV"
|
|
||||||
echo "ANDROID_VERSION_CODE=$code" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Build l'APK signé
|
|
||||||
working-directory: apps/web/android
|
|
||||||
env:
|
|
||||||
ANDROID_KEYSTORE_PATH: release.keystore
|
|
||||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
|
||||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
|
||||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
|
||||||
run: |
|
|
||||||
chmod +x ./gradlew
|
|
||||||
./gradlew assembleRelease -PandroidVersionName="$ANDROID_VERSION_NAME" -PandroidVersionCode="$ANDROID_VERSION_CODE"
|
|
||||||
|
|
||||||
# Nom de fichier stable, jamais versionné : la page d'accueil (#65)
|
|
||||||
# pointe vers /releases/latest (Forgejo n'a pas l'équivalent du
|
|
||||||
# raccourci GitHub /releases/latest/download/<fichier>, seul
|
|
||||||
# /releases/latest lui-même résout) — un nom stable évite au moins que
|
|
||||||
# l'utilisateur voie un nom de fichier différent à chaque release une
|
|
||||||
# fois sur la page.
|
|
||||||
- name: Renomme l'APK sous un nom stable
|
|
||||||
run: cp apps/web/android/app/build/outputs/apk/release/app-release.apk batchcooking.apk
|
|
||||||
|
|
||||||
# v4+ exige l'API "Results" de GitHub, non supportée par ce Forgejo
|
|
||||||
# self-hosted ("GHESNotSupportedError") — v3 (ancienne API Artifacts)
|
|
||||||
# est la seule utilisable ici, voir ci.yml's build-android pour la
|
|
||||||
# même contrainte déjà rencontrée.
|
|
||||||
- uses: https://github.com/actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: batchcooking.apk
|
|
||||||
path: batchcooking.apk
|
|
||||||
|
|
||||||
forgejo-release:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [build-and-push-images, build-android-apk]
|
|
||||||
steps:
|
|
||||||
# Ce runner ne fournit ni docker ni curl par défaut dans ses
|
|
||||||
# conteneurs de job — même constat déjà fait pour docker (voir
|
|
||||||
# build-and-push-images) et pour curl dans ci.yml's test job.
|
|
||||||
- name: Install curl
|
|
||||||
run: apt-get update && apt-get install -y curl
|
|
||||||
|
|
||||||
- uses: https://github.com/actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: https://github.com/actions/download-artifact@v3
|
|
||||||
with:
|
|
||||||
name: batchcooking.apk
|
|
||||||
|
|
||||||
# softprops/action-gh-release (l'action GitHub habituelle pour ça)
|
|
||||||
# échoue systématiquement contre ce Forgejo : sa requête de création
|
|
||||||
# de release répond 405 (vérifié en conditions réelles, tag v0.2.0 —
|
|
||||||
# pas un souci de ref bare comme les autres actions de ce fichier,
|
|
||||||
# une vraie incompatibilité d'API entre les deux plateformes qui n'a
|
|
||||||
# pas de contournement connu côté configuration de l'action). Appelle
|
|
||||||
# directement l'API native de Forgejo plutôt qu'une action tierce
|
|
||||||
# dont la compatibilité GitHub-Forgejo n'est pas garantie.
|
|
||||||
#
|
|
||||||
# Pas d'équivalent à `generate_release_notes` ici (fonctionnalité
|
|
||||||
# propre à GitHub, pas répliquée par l'API Forgejo) — le corps de la
|
|
||||||
# release reste minimal, cohérent avec l'absence d'outillage de
|
|
||||||
# changelog ailleurs dans le repo.
|
|
||||||
- name: Crée (ou récupère) la Release Forgejo
|
|
||||||
id: create-release
|
|
||||||
env:
|
|
||||||
FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
api="$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/releases"
|
|
||||||
body="$(printf '{"tag_name":"%s","name":"%s","draft":false,"prerelease":false}' "$GITHUB_REF_NAME" "$GITHUB_REF_NAME")"
|
|
||||||
|
|
||||||
# POST pour créer la release, code HTTP capturé à part. Si le tag est
|
|
||||||
# déjà connu de Forgejo (tag poussé à la main avant ce workflow, ou
|
|
||||||
# ré-exécution du job), l'API répond 409 : on réutilise alors la
|
|
||||||
# release existante au lieu d'échouer — idempotence, cf. l'incident
|
|
||||||
# du tag v0.2.2 poussé en avance qui a fait tomber ce job sur un 409.
|
|
||||||
code="$(curl -sS -o /tmp/release.json -w '%{http_code}' -X POST "$api" \
|
|
||||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "$body")"
|
|
||||||
|
|
||||||
if [ "$code" = "409" ]; then
|
|
||||||
echo "Release $GITHUB_REF_NAME déjà existante (HTTP 409) — récupération de son id."
|
|
||||||
curl -fsS "$api/tags/$GITHUB_REF_NAME" \
|
|
||||||
-H "Authorization: token $FORGEJO_TOKEN" -o /tmp/release.json
|
|
||||||
elif [ "$code" -lt 200 ] || [ "$code" -ge 300 ]; then
|
|
||||||
echo "Échec de création de la Release (HTTP $code) : $(cat /tmp/release.json)" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
id="$(grep -o '"id":[0-9]*' /tmp/release.json | head -1 | cut -d: -f2)"
|
|
||||||
if [ -z "$id" ]; then
|
|
||||||
echo "Pas d'id de release trouvé : $(cat /tmp/release.json)" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "Release $GITHUB_REF_NAME prête (id=$id)"
|
|
||||||
echo "release-id=$id" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Attache l'APK à la Release
|
|
||||||
env:
|
|
||||||
FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
api="$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/releases/${{ steps.create-release.outputs.release-id }}/assets"
|
|
||||||
curl -fsS -X POST "$api?name=batchcooking.apk" \
|
|
||||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
|
||||||
-F "attachment=@batchcooking.apk"
|
|
||||||
|
|
||||||
notify-portainer:
|
notify-portainer:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: forgejo-release
|
needs: github-release
|
||||||
steps:
|
steps:
|
||||||
- name: Trigger Portainer redeploy
|
- name: Trigger Portainer redeploy
|
||||||
# Best-effort: does nothing (and doesn't fail the workflow) until
|
# Best-effort: does nothing (and doesn't fail the workflow) until
|
||||||
|
|
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -70,7 +70,6 @@ web_modules/
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
!.env.test.example
|
!.env.test.example
|
||||||
!.env.capacitor.example
|
|
||||||
|
|
||||||
# Python virtualenvs/caches for services/tech-step-intent-service (this repo
|
# Python virtualenvs/caches for services/tech-step-intent-service (this repo
|
||||||
# is otherwise all-Node — see that service's own .gitignore for the rest;
|
# is otherwise all-Node — see that service's own .gitignore for the rest;
|
||||||
|
|
@ -134,11 +133,6 @@ dist
|
||||||
# Stores VSCode versions used for testing VSCode extensions
|
# Stores VSCode versions used for testing VSCode extensions
|
||||||
.vscode-test
|
.vscode-test
|
||||||
|
|
||||||
# JetBrains IDEs (Android Studio opens this repo as a project once the
|
|
||||||
# Capacitor Android app exists under apps/web/android — see that folder's own
|
|
||||||
# .gitignore for the generated native project's build artefacts).
|
|
||||||
/.idea/
|
|
||||||
|
|
||||||
# pnpm
|
# pnpm
|
||||||
.pnpm-store
|
.pnpm-store
|
||||||
|
|
||||||
|
|
|
||||||
98
README.md
98
README.md
|
|
@ -207,18 +207,10 @@ depuis la même origine. Les deux autres services du fichier
|
||||||
(`tech-step-intent-service`, `tech-step-llm-worker`) n'exposent eux aucun port
|
(`tech-step-intent-service`, `tech-step-llm-worker`) n'exposent eux aucun port
|
||||||
au host — voir leurs propres README pour leur rôle.
|
au host — voir leurs propres README pour leur rôle.
|
||||||
|
|
||||||
**Registre d'image** : la CI de release (voir plus bas) construit et pousse les
|
**Pas de registre d'image** dans cette configuration : l'instance **Portainer** de
|
||||||
trois images de service — `batchcooking-app`, `batchcooking-intent-service`,
|
production est reliée directement au dépôt Git et reconstruit elle-même
|
||||||
`batchcooking-llm-worker` — sur le **registre de conteneurs intégré à Forgejo**
|
`docker-compose.yml`/`apps/api/Dockerfile` à chaque déploiement — la CI ne pousse
|
||||||
(`git.dev.kyuno.fr/kyuno/…`), taguées à la fois avec le tag de release (`vX.Y.Z`)
|
donc aucune image nulle part.
|
||||||
et `latest`. `docker-compose.yml` référence ces images via `IMAGE_TAG` (variable
|
|
||||||
d'environnement, défaut `latest` — voir `.env.example`), tout en gardant un bloc
|
|
||||||
`build:` local à côté de chaque `image:` : `docker compose pull` récupère les
|
|
||||||
images pré-construites, `docker compose up`/`build` retombe sur un build local si
|
|
||||||
elles sont absentes. L'instance **Portainer** de production peut donc désormais
|
|
||||||
tirer les images pré-construites (stack en mode *pull*, `IMAGE_TAG` pinné sur la
|
|
||||||
release voulue) **ou** continuer à builder elle-même depuis le dépôt Git — les
|
|
||||||
deux modes fonctionnent avec le même `docker-compose.yml`.
|
|
||||||
|
|
||||||
### Release (`.github/workflows/release.yml`)
|
### Release (`.github/workflows/release.yml`)
|
||||||
|
|
||||||
|
|
@ -229,80 +221,14 @@ git tag vX.Y.Z
|
||||||
git push --tags
|
git push --tags
|
||||||
```
|
```
|
||||||
|
|
||||||
Le pipeline enchaîne quatre jobs : `build-and-push-images` (build des 3 images à
|
Le pipeline enchaîne trois jobs : `sanity-build` (build de l'image Docker sans
|
||||||
ce tag + push sur le registre Forgejo sous `vX.Y.Z` et `latest` — un push
|
push, juste pour vérifier qu'elle build encore à ce tag avant de laisser Portainer
|
||||||
réussi vaut aussi vérification que tout build encore à ce commit),
|
redéployer dessus), `github-release` (crée une Release GitHub avec changelog
|
||||||
`build-android-apk` (APK signé, `versionName` dérivé du tag), `forgejo-release`
|
auto-généré à partir des PRs mergées), puis `notify-portainer` — envoie une requête
|
||||||
(crée la Release Forgejo `vX.Y.Z` et y attache `batchcooking.apk`), puis
|
au webhook de redeploy de Portainer si le secret de dépôt `PORTAINER_WEBHOOK_URL`
|
||||||
`notify-portainer` — envoie une requête au webhook de redeploy de Portainer si
|
est configuré (sinon Portainer se resynchronise simplement à son prochain
|
||||||
le secret de dépôt `PORTAINER_WEBHOOK_URL` est configuré (sinon Portainer se
|
polling Git). Pour l'activer : récupérer l'URL du webhook depuis les réglages du
|
||||||
resynchronise à son prochain polling). Pour l'activer : récupérer l'URL du
|
stack Portainer, puis l'ajouter comme secret GitHub `PORTAINER_WEBHOOK_URL`.
|
||||||
webhook depuis les réglages du stack Portainer, puis l'ajouter comme secret de
|
|
||||||
dépôt `PORTAINER_WEBHOOK_URL`.
|
|
||||||
|
|
||||||
## Application mobile (Android)
|
|
||||||
|
|
||||||
`apps/web` est empaqueté dans une coquille [Capacitor](https://capacitorjs.com/)
|
|
||||||
pour Android — le projet natif est versionné dans `apps/web/android/` (issue
|
|
||||||
\#39). L'authentification passe par un jeton Bearer stocké dans le stockage
|
|
||||||
sécurisé de Capacitor (le cookie de session `SameSite=Lax` ne part pas depuis
|
|
||||||
l'origine interne d'une WebView), et `main.tsx` bascule sur `HashRouter` quand
|
|
||||||
`Capacitor.isNativePlatform()` est vrai.
|
|
||||||
|
|
||||||
**Prérequis** : Android Studio (SDK Android + un JDK ; le JBR fourni avec
|
|
||||||
Android Studio suffit). Aucun serveur Postgres/Docker local n'est nécessaire —
|
|
||||||
l'appli parle à l'API HTTPS publique.
|
|
||||||
|
|
||||||
**Build** — depuis `apps/web/` :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. URL publique de l'API, gravée dans le bundle empaqueté (le build web
|
|
||||||
# normal utilise une URL relative « même origine », impossible ici).
|
|
||||||
cp .env.capacitor.example .env.capacitor # puis renseigner VITE_API_URL
|
|
||||||
|
|
||||||
# 2. Build web en mode capacitor + synchronisation dans le projet natif.
|
|
||||||
pnpm build:capacitor
|
|
||||||
npx cap sync android
|
|
||||||
|
|
||||||
# 3. Ouvrir dans Android Studio (build/run/émulateur depuis l'IDE)…
|
|
||||||
npx cap open android
|
|
||||||
# …ou build/run en ligne de commande si un appareil/émulateur est prêt :
|
|
||||||
npx cap run android
|
|
||||||
```
|
|
||||||
|
|
||||||
Le bundle web copié (`apps/web/android/app/src/main/assets/public/`) est
|
|
||||||
git-ignoré : il est régénéré par `pnpm build:capacitor && npx cap sync android`.
|
|
||||||
Les icônes et le splash sont générés depuis `apps/web/assets/logo.svg` (voir
|
|
||||||
`apps/web/assets/README.md`). iOS est hors périmètre pour l'instant (nécessite
|
|
||||||
Xcode/macOS).
|
|
||||||
|
|
||||||
**CI — build automatique d'un APK signé** (`.github/workflows/ci.yml`, job
|
|
||||||
`build-android`, issue #64) : à chaque push sur `main`, une fois les 5 autres
|
|
||||||
jobs verts, un APK release signé est construit et déposé comme artefact de
|
|
||||||
run téléchargeable. Secrets du dépôt Forgejo requis (Réglages du dépôt →
|
|
||||||
Secrets) :
|
|
||||||
|
|
||||||
| Secret | Contenu |
|
|
||||||
|---|---|
|
|
||||||
| `MOBILE_API_URL` | URL HTTPS publique de l'API — même valeur que `VITE_API_URL` dans `.env.capacitor` |
|
|
||||||
| `ANDROID_KEYSTORE_BASE64` | Le keystore de release (`.jks`) encodé en base64 |
|
|
||||||
| `ANDROID_KEYSTORE_PASSWORD` | Mot de passe du keystore |
|
|
||||||
| `ANDROID_KEY_ALIAS` | Alias de la clé dans le keystore |
|
|
||||||
| `ANDROID_KEY_PASSWORD` | Mot de passe de la clé |
|
|
||||||
|
|
||||||
Le keystore doit être généré **une seule fois**, à la main, et rester stable
|
|
||||||
pour toujours : Android refuse d'installer la mise à jour d'une appli signée
|
|
||||||
par une clé différente de celle déjà installée.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
keytool -genkeypair -v -keystore batchcooking-release.jks \
|
|
||||||
-alias batchcooking -keyalg RSA -keysize 2048 -validity 10000
|
|
||||||
base64 -w0 batchcooking-release.jks # → coller comme secret ANDROID_KEYSTORE_BASE64
|
|
||||||
```
|
|
||||||
|
|
||||||
Conserver le fichier `.jks` et ses mots de passe en lieu sûr en dehors du
|
|
||||||
dépôt (jamais commité — voir `apps/web/android/.gitignore`) : le perdre
|
|
||||||
empêche de publier toute future mise à jour de l'appli.
|
|
||||||
|
|
||||||
## Auth (apps/api)
|
## Auth (apps/api)
|
||||||
|
|
||||||
|
|
|
||||||
6
apps/admin-web/.env.example
Normal file
6
apps/admin-web/.env.example
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
# Vite only exposes vars prefixed with VITE_ to client code.
|
||||||
|
# Base URL of the API's /admin/* surface. Empty string = same origin as the
|
||||||
|
# page (correct behind a shared reverse proxy). Native dev overrides it in
|
||||||
|
# apps/admin-web/.env since the Vite dev server (5174) and the API (3000)
|
||||||
|
# are different origins.
|
||||||
|
VITE_ADMIN_API_URL=http://localhost:3000
|
||||||
25
apps/admin-web/Dockerfile
Normal file
25
apps/admin-web/Dockerfile
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
# Its own image (not built into apps/api's) — the admin app is deployed
|
||||||
|
# independently of the main app. Build stage compiles the Vite bundle from
|
||||||
|
# the monorepo; runtime is a plain static nginx serving that bundle.
|
||||||
|
#
|
||||||
|
# Build context is the repo root (like apps/api/Dockerfile) — the workspace
|
||||||
|
# packages (@batch-cooking/shared, @batch-cooking/date-tools) must resolve.
|
||||||
|
FROM node:22-slim AS build
|
||||||
|
RUN corepack enable
|
||||||
|
WORKDIR /repo
|
||||||
|
# Skip Cypress's Electron binary download — this image never runs it.
|
||||||
|
ENV CYPRESS_INSTALL_BINARY=0
|
||||||
|
COPY . .
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
# The admin bundle bakes in VITE_ADMIN_API_URL at build time. Default ""
|
||||||
|
# (same-origin — correct behind a shared reverse proxy); override with
|
||||||
|
# `--build-arg VITE_ADMIN_API_URL=https://api.example.com` when the admin
|
||||||
|
# app is served from a different origin than the API.
|
||||||
|
ARG VITE_ADMIN_API_URL=""
|
||||||
|
ENV VITE_ADMIN_API_URL=$VITE_ADMIN_API_URL
|
||||||
|
RUN pnpm --filter admin-web build
|
||||||
|
|
||||||
|
FROM nginx:alpine AS runtime
|
||||||
|
COPY apps/admin-web/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /repo/apps/admin-web/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 80
|
||||||
28
apps/admin-web/cypress.config.ts
Normal file
28
apps/admin-web/cypress.config.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { addCucumberPreprocessorPlugin } from "@badeball/cypress-cucumber-preprocessor";
|
||||||
|
import { createEsbuildPlugin } from "@badeball/cypress-cucumber-preprocessor/esbuild";
|
||||||
|
import createBundler from "@bahmutov/cypress-esbuild-preprocessor";
|
||||||
|
import { defineConfig } from "cypress";
|
||||||
|
|
||||||
|
// Disable GPU for headless/sandboxed environments where no GPU device is
|
||||||
|
// available — same helper as apps/web's cypress.config.ts.
|
||||||
|
function disableGpu(on: Cypress.PluginEvents) {
|
||||||
|
on("before:browser:launch", (browser, launchOptions) => {
|
||||||
|
if (browser.family === "chromium") {
|
||||||
|
launchOptions.args.push("--disable-gpu", "--no-sandbox");
|
||||||
|
}
|
||||||
|
return launchOptions;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
e2e: {
|
||||||
|
baseUrl: "http://localhost:5174",
|
||||||
|
specPattern: ["cypress/e2e/**/*.cy.ts", "cypress/e2e/**/*.feature"],
|
||||||
|
async setupNodeEvents(on, config) {
|
||||||
|
disableGpu(on);
|
||||||
|
await addCucumberPreprocessorPlugin(on, config);
|
||||||
|
on("file:preprocessor", createBundler({ plugins: [createEsbuildPlugin(config)] }));
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -11,44 +11,44 @@ const adminBody = {
|
||||||
|
|
||||||
describe("Admin layout", () => {
|
describe("Admin layout", () => {
|
||||||
it("redirects to /login when there is no admin session", () => {
|
it("redirects to /login when there is no admin session", () => {
|
||||||
cy.intercept("GET", "**/admin/api/auth/me", {
|
cy.intercept("GET", "**/admin/auth/me", {
|
||||||
statusCode: 401,
|
statusCode: 401,
|
||||||
body: { code: 4011, message: "no" },
|
body: { code: 4011, message: "no" },
|
||||||
});
|
});
|
||||||
cy.visit("/admin/monitoring");
|
cy.visit("/monitoring");
|
||||||
cy.url().should("include", "/admin/login");
|
cy.url().should("include", "/login");
|
||||||
cy.contains("h1", "Administration").should("be.visible");
|
cy.contains("h1", "Administration").should("be.visible");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows the sidebar and navigates between the sections", () => {
|
it("shows the sidebar and navigates between the sections", () => {
|
||||||
cy.intercept("GET", "**/admin/api/auth/me", { statusCode: 200, body: adminBody });
|
cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body: adminBody });
|
||||||
cy.visit("/admin");
|
cy.visit("/");
|
||||||
|
|
||||||
cy.contains("h1", "Tableau de bord").should("be.visible");
|
cy.contains("h1", "Tableau de bord").should("be.visible");
|
||||||
cy.contains(".admin-sidebar__who", "Ops").should("be.visible");
|
cy.contains(".admin-sidebar__who", "Ops").should("be.visible");
|
||||||
|
|
||||||
cy.contains("nav a", "Monitoring").click();
|
cy.contains("nav a", "Monitoring").click();
|
||||||
cy.url().should("include", "/admin/monitoring");
|
cy.url().should("include", "/monitoring");
|
||||||
cy.contains("h1", "Monitoring").should("be.visible");
|
cy.contains("h1", "Monitoring").should("be.visible");
|
||||||
cy.contains("nav a", "Monitoring").should("have.class", "active");
|
cy.contains("nav a", "Monitoring").should("have.class", "active");
|
||||||
|
|
||||||
cy.contains("nav a", "Corrections").click();
|
cy.contains("nav a", "Corrections").click();
|
||||||
cy.url().should("include", "/admin/corrections");
|
cy.url().should("include", "/corrections");
|
||||||
cy.contains("h1", "Corrections").should("be.visible");
|
cy.contains("h1", "Corrections").should("be.visible");
|
||||||
|
|
||||||
cy.intercept("GET", "**/admin/api/catalog/placeholders*", { statusCode: 200, body: [] });
|
cy.intercept("GET", "**/admin/catalog/placeholders*", { statusCode: 200, body: [] });
|
||||||
cy.contains("nav a", "Catalogue").click();
|
cy.contains("nav a", "Catalogue").click();
|
||||||
cy.url().should("include", "/admin/catalogue");
|
cy.url().should("include", "/catalogue");
|
||||||
cy.contains("h1", "Éléments hors-catalogue").should("be.visible");
|
cy.contains("h1", "Ingrédients hors-catalogue").should("be.visible");
|
||||||
|
|
||||||
cy.contains("nav a", "Tableau de bord").click();
|
cy.contains("nav a", "Tableau de bord").click();
|
||||||
cy.url().should("eq", `${Cypress.config().baseUrl}/admin`);
|
cy.url().should("eq", `${Cypress.config().baseUrl}/`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("logs out back to /login", () => {
|
it("logs out back to /login", () => {
|
||||||
cy.intercept("GET", "**/admin/api/auth/me", { statusCode: 200, body: adminBody });
|
cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body: adminBody });
|
||||||
cy.intercept("POST", "**/admin/api/auth/logout", { statusCode: 204 });
|
cy.intercept("POST", "**/admin/auth/logout", { statusCode: 204 });
|
||||||
cy.visit("/admin");
|
cy.visit("/");
|
||||||
|
|
||||||
// Wait until the guarded layout has actually mounted before acting.
|
// Wait until the guarded layout has actually mounted before acting.
|
||||||
cy.contains("h1", "Tableau de bord").should("be.visible");
|
cy.contains("h1", "Tableau de bord").should("be.visible");
|
||||||
|
|
@ -57,6 +57,6 @@ describe("Admin layout", () => {
|
||||||
// guard to /login — no fresh `me` round-trip involved, so nothing to
|
// guard to /login — no fresh `me` round-trip involved, so nothing to
|
||||||
// re-stub here.
|
// re-stub here.
|
||||||
cy.contains("button", "Se déconnecter").click();
|
cy.contains("button", "Se déconnecter").click();
|
||||||
cy.url().should("include", "/admin/login");
|
cy.url().should("include", "/login");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -37,15 +37,15 @@ function pendingGroups() {
|
||||||
describe("Admin catalog — off-catalog ingredients", () => {
|
describe("Admin catalog — off-catalog ingredients", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
cy.viewport(1400, 900);
|
cy.viewport(1400, 900);
|
||||||
cy.intercept("GET", "**/admin/api/auth/me", { statusCode: 200, body: adminBody });
|
cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body: adminBody });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lists placeholder groups newest-impact first with their recipe count and spelling variants", () => {
|
it("lists placeholder groups newest-impact first with their recipe count and spelling variants", () => {
|
||||||
cy.intercept("GET", "**/admin/api/catalog/placeholders*", {
|
cy.intercept("GET", "**/admin/catalog/placeholders*", {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
body: pendingGroups(),
|
body: pendingGroups(),
|
||||||
}).as("getPlaceholders");
|
}).as("getPlaceholders");
|
||||||
cy.visit("/admin/catalogue");
|
cy.visit("/catalogue");
|
||||||
cy.wait("@getPlaceholders");
|
cy.wait("@getPlaceholders");
|
||||||
|
|
||||||
cy.get(".catalog-card").should("have.length", 2);
|
cy.get(".catalog-card").should("have.length", 2);
|
||||||
|
|
@ -57,16 +57,16 @@ describe("Admin catalog — off-catalog ingredients", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("marks a group reviewed and reloads the list", () => {
|
it("marks a group reviewed and reloads the list", () => {
|
||||||
cy.intercept("GET", "**/admin/api/catalog/placeholders*", {
|
cy.intercept("GET", "**/admin/catalog/placeholders*", {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
body: pendingGroups(),
|
body: pendingGroups(),
|
||||||
}).as("getPlaceholders");
|
}).as("getPlaceholders");
|
||||||
cy.intercept("PATCH", "**/admin/api/catalog/placeholders/mark-reviewed", {
|
cy.intercept("PATCH", "**/admin/catalog/placeholders/mark-reviewed", {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
body: { reviewed: 1 },
|
body: { reviewed: 1 },
|
||||||
}).as("markReviewed");
|
}).as("markReviewed");
|
||||||
|
|
||||||
cy.visit("/admin/catalogue");
|
cy.visit("/catalogue");
|
||||||
cy.wait("@getPlaceholders");
|
cy.wait("@getPlaceholders");
|
||||||
|
|
||||||
cy.contains(".catalog-card", "Sumac").contains("button", "Marquer comme traité").click();
|
cy.contains(".catalog-card", "Sumac").contains("button", "Marquer comme traité").click();
|
||||||
|
|
@ -78,59 +78,17 @@ describe("Admin catalog — off-catalog ingredients", () => {
|
||||||
cy.get("@getPlaceholders.all").should("have.length.greaterThan", 1);
|
cy.get("@getPlaceholders.all").should("have.length.greaterThan", 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("switches to the Techniques kind and lists proposed missing techniques (issue #130)", () => {
|
|
||||||
cy.intercept("GET", "**/admin/api/catalog/placeholders*", { statusCode: 200, body: [] });
|
|
||||||
cy.intercept("GET", "**/admin/api/catalog/tech-step-placeholders*", {
|
|
||||||
statusCode: 200,
|
|
||||||
body: [
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
displayName: "Flamber au chalumeau",
|
|
||||||
createdAt: "2026-09-01T10:00:00.000Z",
|
|
||||||
reviewedAt: null,
|
|
||||||
recipeCount: 2,
|
|
||||||
sampleRecipes: [
|
|
||||||
{ id: 1, name: "Crème brûlée" },
|
|
||||||
{ id: 2, name: "Tarte flambée" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}).as("getTechStepPlaceholders");
|
|
||||||
cy.intercept("PATCH", "**/admin/api/catalog/tech-step-placeholders/mark-reviewed", {
|
|
||||||
statusCode: 200,
|
|
||||||
body: { reviewed: 1 },
|
|
||||||
}).as("markTechStepReviewed");
|
|
||||||
|
|
||||||
cy.visit("/admin/catalogue");
|
|
||||||
cy.contains(".catalog-kinds button", "Techniques").click();
|
|
||||||
cy.wait("@getTechStepPlaceholders");
|
|
||||||
|
|
||||||
cy.contains(".catalog-card", "Flamber au chalumeau")
|
|
||||||
.should("contain.text", "2 recette")
|
|
||||||
.and("contain.text", "Crème brûlée");
|
|
||||||
|
|
||||||
cy.contains(".catalog-card", "Flamber au chalumeau")
|
|
||||||
.contains("button", "Marquer comme traité")
|
|
||||||
.click();
|
|
||||||
cy.wait("@markTechStepReviewed")
|
|
||||||
.its("request.body")
|
|
||||||
.should("deep.equal", { techStepIds: [5] });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("switches to the reviewed archive tab", () => {
|
it("switches to the reviewed archive tab", () => {
|
||||||
// Regex, not a glob: the two calls differ only by the `?reviewed=true`
|
cy.intercept("GET", "**/admin/catalog/placeholders", {
|
||||||
// query, and minimatch's `?` is itself a wildcard — a glob can't tell
|
|
||||||
// them apart reliably.
|
|
||||||
cy.intercept("GET", /\/admin\/api\/catalog\/placeholders$/, {
|
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
body: pendingGroups(),
|
body: pendingGroups(),
|
||||||
});
|
});
|
||||||
cy.intercept("GET", /\/admin\/api\/catalog\/placeholders\?reviewed=true$/, {
|
cy.intercept("GET", "**/admin/catalog/placeholders?reviewed=true", {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
body: [],
|
body: [],
|
||||||
}).as("getReviewed");
|
}).as("getReviewed");
|
||||||
|
|
||||||
cy.visit("/admin/catalogue");
|
cy.visit("/catalogue");
|
||||||
cy.contains(".catalog-tabs button", "Traités").click();
|
cy.contains(".catalog-tabs button", "Traités").click();
|
||||||
cy.wait("@getReviewed");
|
cy.wait("@getReviewed");
|
||||||
cy.contains("Aucun ingrédient hors-catalogue").should("be.visible");
|
cy.contains("Aucun ingrédient hors-catalogue").should("be.visible");
|
||||||
|
|
@ -70,24 +70,24 @@ function corrections() {
|
||||||
describe("Admin corrections triage", () => {
|
describe("Admin corrections triage", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
cy.viewport(1400, 1000);
|
cy.viewport(1400, 1000);
|
||||||
cy.intercept("GET", "**/admin/api/auth/me", { statusCode: 200, body: adminBody });
|
cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body: adminBody });
|
||||||
cy.intercept("GET", "**/admin/api/tech-steps/suggestions*", {
|
cy.intercept("GET", "**/admin/tech-steps/suggestions*", {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
body: suggestionGroups(),
|
body: suggestionGroups(),
|
||||||
}).as("getSuggestions");
|
}).as("getSuggestions");
|
||||||
cy.intercept("GET", "**/admin/api/tech-steps/corrections*", {
|
cy.intercept("GET", "**/admin/tech-steps/corrections*", {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
body: corrections(),
|
body: corrections(),
|
||||||
}).as("getCorrections");
|
}).as("getCorrections");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows the caveat, groups suggestions by technique, and applies one", () => {
|
it("shows the caveat, groups suggestions by technique, and applies one", () => {
|
||||||
cy.intercept("PATCH", "**/admin/api/tech-steps/suggestions/11", {
|
cy.intercept("PATCH", "**/admin/tech-steps/suggestions/11", {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
body: { ...suggestionGroups()[0].suggestions[0], status: "applied" },
|
body: { ...suggestionGroups()[0].suggestions[0], status: "applied" },
|
||||||
}).as("patch");
|
}).as("patch");
|
||||||
|
|
||||||
cy.visit("/admin/corrections");
|
cy.visit("/corrections");
|
||||||
cy.wait("@getSuggestions");
|
cy.wait("@getSuggestions");
|
||||||
|
|
||||||
cy.contains(".corrections-caveat", "training_data.py").should("be.visible");
|
cy.contains(".corrections-caveat", "training_data.py").should("be.visible");
|
||||||
|
|
@ -102,7 +102,7 @@ describe("Admin corrections triage", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("generates a training_data.py snippet", () => {
|
it("generates a training_data.py snippet", () => {
|
||||||
cy.intercept("GET", "**/admin/api/tech-steps/training-data-snippet*", {
|
cy.intercept("GET", "**/admin/tech-steps/training-data-snippet*", {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
body: {
|
body: {
|
||||||
techStepKey: "simmer",
|
techStepKey: "simmer",
|
||||||
|
|
@ -116,7 +116,7 @@ describe("Admin corrections triage", () => {
|
||||||
},
|
},
|
||||||
}).as("getSnippet");
|
}).as("getSnippet");
|
||||||
|
|
||||||
cy.visit("/admin/corrections");
|
cy.visit("/corrections");
|
||||||
cy.get(".corrections-panel input").type("simmer");
|
cy.get(".corrections-panel input").type("simmer");
|
||||||
cy.contains(".corrections-panel button", "Générer").click();
|
cy.contains(".corrections-panel button", "Générer").click();
|
||||||
cy.wait("@getSnippet");
|
cy.wait("@getSnippet");
|
||||||
|
|
@ -124,7 +124,7 @@ describe("Admin corrections triage", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("runs the F1 gate and shows the result", () => {
|
it("runs the F1 gate and shows the result", () => {
|
||||||
cy.intercept("POST", "**/admin/api/tech-steps/retrain", {
|
cy.intercept("POST", "**/admin/tech-steps/retrain", {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
body: {
|
body: {
|
||||||
f1: 0.83,
|
f1: 0.83,
|
||||||
|
|
@ -137,7 +137,7 @@ describe("Admin corrections triage", () => {
|
||||||
},
|
},
|
||||||
}).as("retrain");
|
}).as("retrain");
|
||||||
|
|
||||||
cy.visit("/admin/corrections");
|
cy.visit("/corrections");
|
||||||
cy.contains(".corrections-panel--retrain button", "Lancer").click();
|
cy.contains(".corrections-panel--retrain button", "Lancer").click();
|
||||||
cy.wait("@retrain");
|
cy.wait("@retrain");
|
||||||
cy.contains(".retrain-result", "F1 0.830")
|
cy.contains(".retrain-result", "F1 0.830")
|
||||||
|
|
@ -146,7 +146,7 @@ describe("Admin corrections triage", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lists raw corrections including the removals, on the second tab", () => {
|
it("lists raw corrections including the removals, on the second tab", () => {
|
||||||
cy.visit("/admin/corrections");
|
cy.visit("/corrections");
|
||||||
cy.contains(".corrections-tabs button", "Corrections brutes").click();
|
cy.contains(".corrections-tabs button", "Corrections brutes").click();
|
||||||
cy.wait("@getCorrections");
|
cy.wait("@getCorrections");
|
||||||
|
|
||||||
|
|
@ -58,14 +58,14 @@ function metricsFixture() {
|
||||||
describe("Admin dashboard", () => {
|
describe("Admin dashboard", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
cy.viewport(1400, 900);
|
cy.viewport(1400, 900);
|
||||||
cy.intercept("GET", "**/admin/api/auth/me", { statusCode: 200, body: adminBody });
|
cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body: adminBody });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders KPI tiles, a chart per series, and the breakdown lists", () => {
|
it("renders KPI tiles, a chart per series, and the breakdown lists", () => {
|
||||||
cy.intercept("GET", "**/admin/api/metrics*", { statusCode: 200, body: metricsFixture() }).as(
|
cy.intercept("GET", "**/admin/metrics*", { statusCode: 200, body: metricsFixture() }).as(
|
||||||
"getMetrics",
|
"getMetrics",
|
||||||
);
|
);
|
||||||
cy.visit("/admin");
|
cy.visit("/");
|
||||||
cy.wait("@getMetrics").its("request.url").should("include", "days=30");
|
cy.wait("@getMetrics").its("request.url").should("include", "days=30");
|
||||||
|
|
||||||
// KPI tiles — value + label.
|
// KPI tiles — value + label.
|
||||||
|
|
@ -85,11 +85,11 @@ describe("Admin dashboard", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows an error state when the metrics request fails", () => {
|
it("shows an error state when the metrics request fails", () => {
|
||||||
cy.intercept("GET", "**/admin/api/metrics*", {
|
cy.intercept("GET", "**/admin/metrics*", {
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
body: { code: 5000, message: "x" },
|
body: { code: 5000, message: "x" },
|
||||||
});
|
});
|
||||||
cy.visit("/admin");
|
cy.visit("/");
|
||||||
cy.contains("Impossible de charger").should("be.visible");
|
cy.contains("Impossible de charger").should("be.visible");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -6,19 +6,19 @@ Feature: Admin login
|
||||||
Scenario: A wrong password shows a translated error, no redirect
|
Scenario: A wrong password shows a translated error, no redirect
|
||||||
Given the admin session check returns unauthenticated
|
Given the admin session check returns unauthenticated
|
||||||
And admin login fails with invalid credentials
|
And admin login fails with invalid credentials
|
||||||
When I visit "/admin/login"
|
When I visit "/login"
|
||||||
And I fill in the "email" field with "ops@example.com"
|
And I fill in the "email" field with "ops@example.com"
|
||||||
And I fill in the "password" field with "wrong"
|
And I fill in the "password" field with "wrong"
|
||||||
And I click the button "Se connecter"
|
And I click the button "Se connecter"
|
||||||
Then I should see "Email ou mot de passe incorrect"
|
Then I should see "Email ou mot de passe incorrect"
|
||||||
And the URL should include "/admin/login"
|
And the URL should include "/login"
|
||||||
|
|
||||||
Scenario: A correct login lands on the dashboard
|
Scenario: A correct login lands on the dashboard
|
||||||
Given the admin session check returns unauthenticated
|
Given the admin session check returns unauthenticated
|
||||||
And admin login succeeds as "Ops"
|
And admin login succeeds as "Ops"
|
||||||
When I visit "/admin/login"
|
When I visit "/login"
|
||||||
And I fill in the "email" field with "ops@example.com"
|
And I fill in the "email" field with "ops@example.com"
|
||||||
And I fill in the "password" field with "correct-horse"
|
And I fill in the "password" field with "correct-horse"
|
||||||
And I click the button "Se connecter"
|
And I click the button "Se connecter"
|
||||||
Then the URL should not include "/admin/login"
|
Then the URL should not include "/login"
|
||||||
And I should see the heading "Tableau de bord"
|
And I should see the heading "Tableau de bord"
|
||||||
24
apps/admin-web/cypress/e2e/login.ts
Normal file
24
apps/admin-web/cypress/e2e/login.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { Given } from "@badeball/cypress-cucumber-preprocessor";
|
||||||
|
|
||||||
|
const adminBody = {
|
||||||
|
id: 1,
|
||||||
|
email: "ops@example.com",
|
||||||
|
name: "Ops",
|
||||||
|
createdAt: "2026-08-01T00:00:00.000Z",
|
||||||
|
lastLoginAt: "2026-08-28T09:00:00.000Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
Given("admin login fails with invalid credentials", () => {
|
||||||
|
cy.intercept("POST", "**/admin/auth/login", {
|
||||||
|
statusCode: 401,
|
||||||
|
body: { code: 4010, message: "Invalid email or password" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Given("admin login succeeds as {string}", (name: string) => {
|
||||||
|
const body = { ...adminBody, name, email: `${name.toLowerCase()}@example.com` };
|
||||||
|
cy.intercept("POST", "**/admin/auth/login", { statusCode: 200, body });
|
||||||
|
// After navigate("/"), RequireAdmin re-checks the session — from now on it
|
||||||
|
// must report authenticated (last matching intercept wins).
|
||||||
|
cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body });
|
||||||
|
});
|
||||||
83
apps/admin-web/cypress/e2e/monitoring.cy.ts
Normal file
83
apps/admin-web/cypress/e2e/monitoring.cy.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
// Mocks the admin API via cy.intercept — no live backend.
|
||||||
|
|
||||||
|
const adminBody = {
|
||||||
|
id: 1,
|
||||||
|
email: "ops@example.com",
|
||||||
|
name: "Ops",
|
||||||
|
createdAt: "2026-08-01T00:00:00.000Z",
|
||||||
|
lastLoginAt: "2026-08-28T09:00:00.000Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
function monitoringFixture() {
|
||||||
|
return {
|
||||||
|
generatedAt: "2026-08-28T09:15:00.000Z",
|
||||||
|
services: [
|
||||||
|
{
|
||||||
|
key: "postgres",
|
||||||
|
status: "up",
|
||||||
|
latencyMs: 3.2,
|
||||||
|
detail: null,
|
||||||
|
checkedAt: "2026-08-28T09:15:00.000Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "api",
|
||||||
|
status: "up",
|
||||||
|
latencyMs: 0,
|
||||||
|
detail: "uptime 3 h 12 min · RSS 120 Mo",
|
||||||
|
checkedAt: "2026-08-28T09:15:00.000Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "intent-service",
|
||||||
|
status: "down",
|
||||||
|
latencyMs: null,
|
||||||
|
detail: "fetch failed",
|
||||||
|
checkedAt: "2026-08-28T09:15:00.000Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "tech-step-llm-worker",
|
||||||
|
status: "degraded",
|
||||||
|
latencyMs: null,
|
||||||
|
detail: "dernier battement il y a 9 j",
|
||||||
|
checkedAt: "2026-08-28T09:15:00.000Z",
|
||||||
|
lastRunAt: "2026-08-19T03:00:00.000Z",
|
||||||
|
lastResult: { job: "audit-low-confidence", ok: true, counts: { suggestions: 2 } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Admin monitoring", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.viewport(1400, 900);
|
||||||
|
cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body: adminBody });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders one card per service with its status and details", () => {
|
||||||
|
cy.intercept("GET", "**/admin/monitoring", { statusCode: 200, body: monitoringFixture() }).as(
|
||||||
|
"getMonitoring",
|
||||||
|
);
|
||||||
|
cy.visit("/monitoring");
|
||||||
|
cy.wait("@getMonitoring");
|
||||||
|
|
||||||
|
cy.get(".monitoring-card").should("have.length", 4);
|
||||||
|
|
||||||
|
cy.contains(".monitoring-card", "Base de données")
|
||||||
|
.should("have.class", "monitoring-card--up")
|
||||||
|
.and("contain.text", "3.2 ms");
|
||||||
|
cy.contains(".monitoring-card", "Service NLP (spaCy)")
|
||||||
|
.should("have.class", "monitoring-card--down")
|
||||||
|
.and("contain.text", "Hors service");
|
||||||
|
cy.contains(".monitoring-card", "Worker LLM")
|
||||||
|
.should("have.class", "monitoring-card--degraded")
|
||||||
|
.and("contain.text", "audit-low-confidence");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an error state when the request fails", () => {
|
||||||
|
cy.intercept("GET", "**/admin/monitoring", {
|
||||||
|
statusCode: 500,
|
||||||
|
body: { code: 5000, message: "x" },
|
||||||
|
});
|
||||||
|
cy.visit("/monitoring");
|
||||||
|
cy.contains("Impossible de charger").should("be.visible");
|
||||||
|
});
|
||||||
|
});
|
||||||
3
apps/admin-web/cypress/support/e2e.ts
Normal file
3
apps/admin-web/cypress/support/e2e.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
// Cypress support file — global config and custom commands go here as the
|
||||||
|
// admin app grows. Same minimal starting point as apps/web's e2e.ts.
|
||||||
|
export {};
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { Given, Then, When } from "@badeball/cypress-cucumber-preprocessor";
|
||||||
|
|
||||||
|
// Steps shared across admin feature specs — navigation and generic UI
|
||||||
|
// assertions. Anything specific to one feature (its own API mocks, its own
|
||||||
|
// DOM structure) lives in that feature's own `<name>.ts` step file.
|
||||||
|
//
|
||||||
|
// Every admin API call is mocked via `cy.intercept` — the Cypress suite
|
||||||
|
// never runs a live backend; apps/api's own Mocha suite covers real
|
||||||
|
// `/admin/*` behaviour.
|
||||||
|
|
||||||
|
Given("the admin session check returns unauthenticated", () => {
|
||||||
|
cy.intercept("GET", "**/admin/auth/me", { statusCode: 401, body: { code: 4011, message: "no" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
Given("I am signed in as admin {string}", (name: string) => {
|
||||||
|
cy.intercept("GET", "**/admin/auth/me", {
|
||||||
|
statusCode: 200,
|
||||||
|
body: {
|
||||||
|
id: 1,
|
||||||
|
email: `${name.toLowerCase()}@example.com`,
|
||||||
|
name,
|
||||||
|
createdAt: "2026-08-01T00:00:00.000Z",
|
||||||
|
lastLoginAt: "2026-08-28T09:00:00.000Z",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
When("I visit {string}", (path: string) => {
|
||||||
|
cy.visit(path);
|
||||||
|
});
|
||||||
|
|
||||||
|
When("I fill in the {string} field with {string}", (fieldId: string, value: string) => {
|
||||||
|
cy.get(`#${fieldId}`).clear().type(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
When("I click the button {string}", (text: string) => {
|
||||||
|
cy.contains("button", text).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("the URL should include {string}", (fragment: string) => {
|
||||||
|
cy.url().should("include", fragment);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("the URL should not include {string}", (fragment: string) => {
|
||||||
|
cy.url().should("not.include", fragment);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("I should see {string}", (text: string) => {
|
||||||
|
cy.contains(text).should("be.visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("I should see the heading {string}", (text: string) => {
|
||||||
|
cy.contains("h1", text).should("be.visible");
|
||||||
|
});
|
||||||
12
apps/admin-web/index.html
Normal file
12
apps/admin-web/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>batchCooking — Admin</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
20
apps/admin-web/nginx.conf
Normal file
20
apps/admin-web/nginx.conf
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
# Static host for the built admin SPA. Client-side routing (react-router)
|
||||||
|
# means any unknown path must fall back to index.html rather than 404 —
|
||||||
|
# same reason apps/api serves its own SPA that way.
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Long-cache the fingerprinted assets Vite emits; never cache the HTML
|
||||||
|
# entry point so a new deploy is picked up immediately.
|
||||||
|
location /assets/ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
42
apps/admin-web/package.json
Normal file
42
apps/admin-web/package.json
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
{
|
||||||
|
"name": "admin-web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 0.0.0.0 --port 5174",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview --port 5174",
|
||||||
|
"test": "echo \"no unit tests yet\" && exit 0",
|
||||||
|
"cy:open": "cypress open",
|
||||||
|
"cy:run": "cypress run",
|
||||||
|
"e2e": "start-server-and-test dev http://localhost:5174 cy:run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@batch-cooking/date-tools": "workspace:*",
|
||||||
|
"@batch-cooking/shared": "workspace:*",
|
||||||
|
"i18next": "^26.3.6",
|
||||||
|
"lucide-react": "^1.32.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-i18next": "^17.0.11",
|
||||||
|
"react-router-dom": "^7.18.2",
|
||||||
|
"recharts": "^2.15.0",
|
||||||
|
"zod": "^3.25.76"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@badeball/cypress-cucumber-preprocessor": "22.2.0",
|
||||||
|
"@bahmutov/cypress-esbuild-preprocessor": "2.2.8",
|
||||||
|
"@cypress/vite-dev-server": "5.2.1",
|
||||||
|
"@types/node": "^22.9.0",
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@vitejs/plugin-react": "^4.3.3",
|
||||||
|
"cypress": "13.17.0",
|
||||||
|
"esbuild": "0.21.5",
|
||||||
|
"sass": "^1.102.0",
|
||||||
|
"start-server-and-test": "^2.0.8",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"vite": "^5.4.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
36
apps/admin-web/src/App.tsx
Normal file
36
apps/admin-web/src/App.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
|
import { RequireAdmin } from "./features/auth/RequireAdmin";
|
||||||
|
import { AdminLayout } from "./layouts/AdminLayout";
|
||||||
|
import { CatalogPage } from "./pages/catalog/CatalogPage";
|
||||||
|
import { CorrectionsPage } from "./pages/corrections/CorrectionsPage";
|
||||||
|
import { DashboardPage } from "./pages/dashboard/DashboardPage";
|
||||||
|
import { LoginPage } from "./pages/login/LoginPage";
|
||||||
|
import { MonitoringPage } from "./pages/monitoring/MonitoringPage";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin app route table. `/login` is the only unauthenticated route;
|
||||||
|
* everything else is nested under one `RequireAdmin` + `AdminLayout` parent
|
||||||
|
* (the guard + sidebar chrome applied once), same shape as apps/web's
|
||||||
|
* `App.tsx`. Unknown paths fall back to `/`, which redirects to `/login`
|
||||||
|
* when there's no admin session.
|
||||||
|
*/
|
||||||
|
export function App() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route
|
||||||
|
element={
|
||||||
|
<RequireAdmin>
|
||||||
|
<AdminLayout />
|
||||||
|
</RequireAdmin>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route path="/" element={<DashboardPage />} />
|
||||||
|
<Route path="/monitoring" element={<MonitoringPage />} />
|
||||||
|
<Route path="/corrections" element={<CorrectionsPage />} />
|
||||||
|
<Route path="/catalogue" element={<CatalogPage />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
192
apps/admin-web/src/api/client.ts
Normal file
192
apps/admin-web/src/api/client.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
import {
|
||||||
|
type AdminLoginInput,
|
||||||
|
type AdminUserView,
|
||||||
|
type ApiErrorResponse,
|
||||||
|
type CatalogPlaceholderGroupView,
|
||||||
|
type CorrectionAdminView,
|
||||||
|
ErrorCode,
|
||||||
|
type MarkPlaceholdersReviewedInput,
|
||||||
|
type MetricsView,
|
||||||
|
type MonitoringView,
|
||||||
|
type PruneOrphansResultView,
|
||||||
|
type RetrainRequestInput,
|
||||||
|
type RetrainResultView,
|
||||||
|
type TrainingDataSnippetView,
|
||||||
|
type TrainingSuggestionAdminView,
|
||||||
|
type TrainingSuggestionGroupView,
|
||||||
|
type UpdateTrainingSuggestionInput,
|
||||||
|
} from "@batch-cooking/shared";
|
||||||
|
|
||||||
|
/** Builds a `?a=b&c=d` string from defined values only. */
|
||||||
|
function query(params: Record<string, string | undefined>): string {
|
||||||
|
const entries = Object.entries(params).filter(
|
||||||
|
(entry): entry is [string, string] => entry[1] !== undefined && entry[1] !== "",
|
||||||
|
);
|
||||||
|
return entries.length === 0 ? "" : `?${new URLSearchParams(entries).toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base URL of the admin API surface, configurable via `VITE_ADMIN_API_URL`
|
||||||
|
* (see `.env.example`). Defaults to `""` (same origin) — correct behind a
|
||||||
|
* shared reverse proxy; native dev overrides it to `http://localhost:3000`
|
||||||
|
* in `apps/admin-web/.env` since the Vite dev server (5174) and the API
|
||||||
|
* (3000) are different origins.
|
||||||
|
*/
|
||||||
|
const ADMIN_API_BASE_URL: string = import.meta.env.VITE_ADMIN_API_URL ?? "";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown by {@link AdminApiClient} on any non-2xx response — carries the
|
||||||
|
* same {@link ErrorCode} the API returned. Same shape as apps/web's
|
||||||
|
* `ApiError`; kept separate rather than shared so the two apps' transport
|
||||||
|
* layers stay independent.
|
||||||
|
*/
|
||||||
|
export class ApiError extends Error {
|
||||||
|
public readonly status: number;
|
||||||
|
public readonly code: ErrorCode;
|
||||||
|
public readonly fieldErrors?: Record<string, string[] | undefined>;
|
||||||
|
|
||||||
|
public constructor(status: number, body: ApiErrorResponse) {
|
||||||
|
super(body.message);
|
||||||
|
this.name = "ApiError";
|
||||||
|
this.status = status;
|
||||||
|
this.code = body.code;
|
||||||
|
this.fieldErrors = body.details;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin fetch wrapper around the `/admin/*` endpoints — same design as
|
||||||
|
* apps/web's `ApiClient` (a class for cohesion/extensibility, one shared
|
||||||
|
* stateless instance). Every request sends credentials so the
|
||||||
|
* `admin_session` httpOnly cookie round-trips.
|
||||||
|
*/
|
||||||
|
export class AdminApiClient {
|
||||||
|
/**
|
||||||
|
* Performs a JSON request against the admin API and returns the parsed body.
|
||||||
|
*
|
||||||
|
* @throws {ApiError} if the response status is not in the 2xx range.
|
||||||
|
*/
|
||||||
|
private async _request<TResponseBody>(
|
||||||
|
path: string,
|
||||||
|
options: RequestInit = {},
|
||||||
|
): Promise<TResponseBody> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${ADMIN_API_BASE_URL}${path}`, {
|
||||||
|
...options,
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json", ...options.headers },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = (await response.json().catch(() => null)) as ApiErrorResponse | null;
|
||||||
|
throw new ApiError(
|
||||||
|
response.status,
|
||||||
|
body ?? { code: ErrorCode.INTERNAL_ERROR, message: "Something went wrong" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 204) {
|
||||||
|
return undefined as TResponseBody;
|
||||||
|
}
|
||||||
|
return (await response.json()) as TResponseBody;
|
||||||
|
} catch (err) {
|
||||||
|
// Rethrown as-is — callers surface it their own way; this is just the
|
||||||
|
// one place the fetch/`await` sits in a try/catch per the repo's rule.
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verifies admin credentials and starts an admin session. */
|
||||||
|
public login(input: AdminLoginInput): Promise<AdminUserView> {
|
||||||
|
return this._request("/admin/auth/login", { method: "POST", body: JSON.stringify(input) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ends the current admin session. */
|
||||||
|
public logout(): Promise<void> {
|
||||||
|
return this._request("/admin/auth/logout", { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetches the currently authenticated admin — rejects with `NOT_AUTHENTICATED` if there's no session. */
|
||||||
|
public me(): Promise<AdminUserView> {
|
||||||
|
return this._request("/admin/auth/me");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Usage metrics for the dashboard — snapshot totals + `days` (7–365) of daily time series. */
|
||||||
|
public getMetrics(days: number): Promise<MetricsView> {
|
||||||
|
return this._request(`/admin/metrics?days=${days}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Live health of Postgres, the API, the intent-service and the LLM worker — polled by the monitoring board. */
|
||||||
|
public getMonitoring(): Promise<MonitoringView> {
|
||||||
|
return this._request("/admin/monitoring");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Training suggestions, grouped by technique, filtered by the given (all-optional) criteria. */
|
||||||
|
public getSuggestions(filters: {
|
||||||
|
status?: string;
|
||||||
|
sourceType?: string;
|
||||||
|
techStepKey?: string;
|
||||||
|
locale?: string;
|
||||||
|
}): Promise<TrainingSuggestionGroupView[]> {
|
||||||
|
return this._request(`/admin/tech-steps/suggestions${query(filters)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Raw user corrections, including the "no technique here" removals. */
|
||||||
|
public getCorrections(filters: {
|
||||||
|
consumed?: string;
|
||||||
|
hasCorrectedTechStep?: string;
|
||||||
|
}): Promise<CorrectionAdminView[]> {
|
||||||
|
return this._request(`/admin/tech-steps/corrections${query(filters)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Edits a suggestion's proposed synonyms/utterances and/or its status. */
|
||||||
|
public updateSuggestion(
|
||||||
|
id: number,
|
||||||
|
body: UpdateTrainingSuggestionInput,
|
||||||
|
): Promise<TrainingSuggestionAdminView> {
|
||||||
|
return this._request(`/admin/tech-steps/suggestions/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The ready-to-paste `training_data.py` block aggregating suggestions for one technique/locale/status. */
|
||||||
|
public getTrainingDataSnippet(params: {
|
||||||
|
techStepKey: string;
|
||||||
|
locale?: string;
|
||||||
|
status?: string;
|
||||||
|
}): Promise<TrainingDataSnippetView> {
|
||||||
|
return this._request(`/admin/tech-steps/training-data-snippet${query(params)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs the F1 gate + backfill (+ marks suggestion ids). Rejects with `RETRAIN_ALREADY_RUNNING` if one is in flight. */
|
||||||
|
public retrain(body: RetrainRequestInput): Promise<RetrainResultView> {
|
||||||
|
return this._request("/admin/tech-steps/retrain", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Off-catalog ingredient "placeholders" users typed, grouped by normalized name. `reviewed` omitted/`"false"` = the still-to-triage list, `"true"` = the archive. */
|
||||||
|
public getPlaceholders(reviewed?: "true" | "false"): Promise<CatalogPlaceholderGroupView[]> {
|
||||||
|
return this._request(`/admin/catalog/placeholders${query({ reviewed })}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Marks the given placeholder ingredient ids as triaged (`reviewedAt`). */
|
||||||
|
public markPlaceholdersReviewed(
|
||||||
|
body: MarkPlaceholdersReviewedInput,
|
||||||
|
): Promise<{ reviewed: number }> {
|
||||||
|
return this._request("/admin/catalog/placeholders/mark-reviewed", {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deletes placeholder rows no recipe references any more. */
|
||||||
|
public pruneOrphanPlaceholders(): Promise<PruneOrphansResultView> {
|
||||||
|
return this._request("/admin/catalog/placeholders/prune-orphans", { method: "POST" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Single shared instance — stateless, same reasoning as apps/web's `apiClient`. */
|
||||||
|
export const adminApiClient = new AdminApiClient();
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import type { AdminLoginInput, AdminUserView } from "@batch-cooking/shared";
|
import type { AdminLoginInput, AdminUserView } from "@batch-cooking/shared";
|
||||||
import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from "react";
|
import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from "react";
|
||||||
import { adminApiClient } from "../../api/admin-client";
|
import { adminApiClient } from "../../api/client";
|
||||||
|
|
||||||
/** Auth state/actions exposed via {@link useAdminAuth} — the admin-app counterpart of apps/web's `AuthContext`. */
|
/** Auth state/actions exposed via {@link useAdminAuth} — the admin-app counterpart of apps/web's `AuthContext`. */
|
||||||
interface AdminAuthContextValue {
|
interface AdminAuthContextValue {
|
||||||
/** Currently authenticated admin, or `null` if no active session. */
|
/** Currently authenticated admin, or `null` if no active session. */
|
||||||
admin: AdminUserView | null;
|
admin: AdminUserView | null;
|
||||||
/** True only while the initial `GET /admin/api/auth/me` check is pending — lets `RequireAdmin` avoid a premature redirect. */
|
/** True only while the initial `GET /admin/auth/me` check is pending — lets `RequireAdmin` avoid a premature redirect. */
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
/** Verifies credentials and updates `admin` on success. Throws `ApiError` on failure. */
|
/** Verifies credentials and updates `admin` on success. Throws `ApiError` on failure. */
|
||||||
login: (input: AdminLoginInput) => Promise<void>;
|
login: (input: AdminLoginInput) => Promise<void>;
|
||||||
|
|
@ -18,7 +18,7 @@ const AdminAuthContext = createContext<AdminAuthContextValue | null>(null);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provides admin authentication state to the whole app. On mount, calls
|
* Provides admin authentication state to the whole app. On mount, calls
|
||||||
* `GET /admin/api/auth/me` once to restore the session from the `admin_session`
|
* `GET /admin/auth/me` once to restore the session from the `admin_session`
|
||||||
* httpOnly cookie (if any) — same "reload keeps you logged in" behaviour as
|
* httpOnly cookie (if any) — same "reload keeps you logged in" behaviour as
|
||||||
* apps/web's `AuthProvider`.
|
* apps/web's `AuthProvider`.
|
||||||
*/
|
*/
|
||||||
|
|
@ -4,10 +4,9 @@ import { useAdminAuth } from "./AdminAuthContext";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Route guard for every admin page. Renders nothing while the initial
|
* Route guard for every admin page. Renders nothing while the initial
|
||||||
* `GET /admin/api/auth/me` check is pending (avoids a flash-then-redirect);
|
* `GET /admin/auth/me` check is pending (avoids a flash-then-redirect);
|
||||||
* once resolved, renders `children` or redirects to `/admin/login`. Mirror
|
* once resolved, renders `children` or redirects to `/login`. Mirror of
|
||||||
* of `RequireAuth` (`features/auth/`), but keyed to the separate admin
|
* apps/web's `RequireAuth`.
|
||||||
* session (`admin_session` cookie), not the user one.
|
|
||||||
*/
|
*/
|
||||||
export function RequireAdmin({ children }: { children: ReactNode }) {
|
export function RequireAdmin({ children }: { children: ReactNode }) {
|
||||||
const { admin, isLoading } = useAdminAuth();
|
const { admin, isLoading } = useAdminAuth();
|
||||||
|
|
@ -16,7 +15,7 @@ export function RequireAdmin({ children }: { children: ReactNode }) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!admin) {
|
if (!admin) {
|
||||||
return <Navigate to="/admin/login" replace />;
|
return <Navigate to="/login" replace />;
|
||||||
}
|
}
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
22
apps/admin-web/src/i18n/i18n.ts
Normal file
22
apps/admin-web/src/i18n/i18n.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import i18next from "i18next";
|
||||||
|
import { initReactI18next } from "react-i18next";
|
||||||
|
import fr from "../locales/fr/translation.json";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* i18next instance for the admin app, imported once for its side effect
|
||||||
|
* (`main.tsx`) before anything renders. Only French exists today — same
|
||||||
|
* setup as apps/web's `i18n/i18n.ts`, its own separate locale file so the
|
||||||
|
* two apps' copy never has to be kept identical. `packages/shared`'s
|
||||||
|
* `ErrorCode` member names double as keys under the `errors` namespace
|
||||||
|
* (see `services/error-message.service.ts`).
|
||||||
|
*/
|
||||||
|
void i18next.use(initReactI18next).init({
|
||||||
|
resources: {
|
||||||
|
fr: { translation: fr },
|
||||||
|
},
|
||||||
|
lng: "fr",
|
||||||
|
fallbackLng: "fr",
|
||||||
|
interpolation: { escapeValue: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
export default i18next;
|
||||||
|
|
@ -1,32 +1,19 @@
|
||||||
import {
|
import { Activity, LayoutDashboard, ListChecks, PackageSearch } from "lucide-react";
|
||||||
Activity,
|
|
||||||
GraduationCap,
|
|
||||||
Inbox,
|
|
||||||
LayoutDashboard,
|
|
||||||
ListChecks,
|
|
||||||
PackageSearch,
|
|
||||||
Users,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
||||||
import { useAdminAuth } from "../features/admin/AdminAuthContext";
|
import { useAdminAuth } from "../features/auth/AdminAuthContext";
|
||||||
import "./AdminLayout.scss";
|
import "./AdminLayout.scss";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One entry in the admin sidebar's nav. `key` maps to `admin.nav.<key>` in
|
* One entry in the admin sidebar's nav. `key` maps to `admin.nav.<key>` in
|
||||||
* the locale file — adding a section is one array entry plus one locale key.
|
* the locale file — adding a section is one array entry plus one locale key.
|
||||||
* Paths are absolute under `/admin` (the admin route group lives inside
|
|
||||||
* `apps/web`'s `App.tsx`, mounted at `/admin`).
|
|
||||||
*/
|
*/
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ to: "/admin", key: "dashboard", Icon: LayoutDashboard, end: true },
|
{ to: "/", key: "dashboard", Icon: LayoutDashboard, end: true },
|
||||||
{ to: "/admin/utilisateurs", key: "users", Icon: Users, end: false },
|
{ to: "/monitoring", key: "monitoring", Icon: Activity, end: false },
|
||||||
{ to: "/admin/monitoring", key: "monitoring", Icon: Activity, end: false },
|
{ to: "/corrections", key: "corrections", Icon: ListChecks, end: false },
|
||||||
{ to: "/admin/corrections", key: "corrections", Icon: ListChecks, end: false },
|
{ to: "/catalogue", key: "catalog", Icon: PackageSearch, end: false },
|
||||||
{ to: "/admin/catalogue", key: "catalog", Icon: PackageSearch, end: false },
|
|
||||||
{ to: "/admin/didacticiels", key: "tours", Icon: GraduationCap, end: false },
|
|
||||||
{ to: "/admin/messages", key: "messages", Icon: Inbox, end: false },
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -46,12 +33,12 @@ export function AdminLayout() {
|
||||||
setIsLoggingOut(true);
|
setIsLoggingOut(true);
|
||||||
try {
|
try {
|
||||||
await logout();
|
await logout();
|
||||||
void navigate("/admin/login");
|
void navigate("/login");
|
||||||
} catch {
|
} catch {
|
||||||
// Even if the network call failed, the local session state was
|
// Even if the network call failed, the local session state was
|
||||||
// cleared optimistically enough for the guard to bounce to /login;
|
// cleared optimistically enough for the guard to bounce to /login;
|
||||||
// nothing useful to show the operator here.
|
// nothing useful to show the operator here.
|
||||||
void navigate("/admin/login");
|
void navigate("/login");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
18
apps/admin-web/src/lib/zod-errors.ts
Normal file
18
apps/admin-web/src/lib/zod-errors.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import type { ZodError } from "zod";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flattens a zod validation error into `{ fieldName: firstMessage }` for
|
||||||
|
* inline display under each form field — verbatim copy of apps/web's
|
||||||
|
* `lib/zod-errors.ts` (only the first message per field, enough for the
|
||||||
|
* single-rule-per-field schemas used here).
|
||||||
|
*/
|
||||||
|
export function fieldErrorsFrom(error: ZodError): Record<string, string> {
|
||||||
|
const fieldErrors = error.flatten().fieldErrors;
|
||||||
|
const firstMessagePerField: Record<string, string> = {};
|
||||||
|
for (const [field, messages] of Object.entries(fieldErrors)) {
|
||||||
|
if (messages?.[0]) {
|
||||||
|
firstMessagePerField[field] = messages[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return firstMessagePerField;
|
||||||
|
}
|
||||||
148
apps/admin-web/src/locales/fr/translation.json
Normal file
148
apps/admin-web/src/locales/fr/translation.json
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
{
|
||||||
|
"errors": {
|
||||||
|
"VALIDATION_ERROR": "Erreur de validation",
|
||||||
|
"INVALID_CREDENTIALS": "Email ou mot de passe incorrect",
|
||||||
|
"NOT_AUTHENTICATED": "Vous devez être connecté",
|
||||||
|
"NOT_FOUND": "Ressource introuvable",
|
||||||
|
"TECH_STEP_NOT_FOUND": "Cette technique n'existe pas",
|
||||||
|
"RETRAIN_ALREADY_RUNNING": "Un ré-entraînement est déjà en cours",
|
||||||
|
"INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard"
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"common": {
|
||||||
|
"comingSoon": "Section à venir.",
|
||||||
|
"loading": "Chargement…",
|
||||||
|
"loadError": "Impossible de charger les données, réessayez plus tard."
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "Administration",
|
||||||
|
"emailLabel": "Email",
|
||||||
|
"passwordLabel": "Mot de passe",
|
||||||
|
"submit": "Se connecter",
|
||||||
|
"submitting": "Connexion…"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"dashboard": "Tableau de bord",
|
||||||
|
"monitoring": "Monitoring",
|
||||||
|
"corrections": "Corrections",
|
||||||
|
"catalog": "Catalogue"
|
||||||
|
},
|
||||||
|
"layout": {
|
||||||
|
"logout": "Se déconnecter"
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"title": "Tableau de bord",
|
||||||
|
"lead": "Métriques d'utilisation de l'application.",
|
||||||
|
"windowTotal": "{{n}} sur 30 j",
|
||||||
|
"recipesBySource": "Recettes importées par source",
|
||||||
|
"noImports": "Aucune recette importée.",
|
||||||
|
"events": "Évènements enregistrés (30 j)",
|
||||||
|
"kpi": {
|
||||||
|
"users": "Utilisateurs",
|
||||||
|
"households": "Foyers",
|
||||||
|
"activeHouseholds": "Foyers actifs",
|
||||||
|
"recipes": "Recettes",
|
||||||
|
"recipesImported": "Recettes importées",
|
||||||
|
"plannings": "Plannings",
|
||||||
|
"planningItems": "Créneaux planifiés",
|
||||||
|
"favorites": "Favoris",
|
||||||
|
"corrections": "Corrections",
|
||||||
|
"correctionsUnconsumed": "Corrections à traiter",
|
||||||
|
"trainingSuggestions": "Suggestions d'entraînement",
|
||||||
|
"admins": "Administrateurs"
|
||||||
|
},
|
||||||
|
"series": {
|
||||||
|
"signups": "Inscriptions",
|
||||||
|
"recipesCreated": "Recettes créées",
|
||||||
|
"planningItemsAdded": "Ajouts au planning",
|
||||||
|
"correctionsSubmitted": "Corrections soumises",
|
||||||
|
"trainingSuggestions": "Suggestions générées"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"monitoring": {
|
||||||
|
"title": "Monitoring",
|
||||||
|
"lead": "Santé des microservices et de la base de données.",
|
||||||
|
"lastChecked": "Dernière vérification à {{time}}",
|
||||||
|
"latency": "Latence",
|
||||||
|
"detail": "Détail",
|
||||||
|
"lastRun": "Dernier job",
|
||||||
|
"never": "jamais",
|
||||||
|
"jobFailed": "échec",
|
||||||
|
"status": {
|
||||||
|
"up": "OK",
|
||||||
|
"degraded": "Dégradé",
|
||||||
|
"down": "Hors service",
|
||||||
|
"unknown": "Inconnu"
|
||||||
|
},
|
||||||
|
"service": {
|
||||||
|
"postgres": "Base de données",
|
||||||
|
"api": "API",
|
||||||
|
"intent-service": "Service NLP (spaCy)",
|
||||||
|
"tech-step-llm-worker": "Worker LLM"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"corrections": {
|
||||||
|
"title": "Corrections",
|
||||||
|
"lead": "Tri des corrections utilisateur pour le ré-entraînement NLP.",
|
||||||
|
"caveat": "Le gate F1 + backfill n'a de sens qu'APRÈS avoir édité training_data.py à la main et redémarré le service NLP (il ne s'entraîne qu'au démarrage). Cet écran ne peut faire ni l'un ni l'autre.",
|
||||||
|
"noSuggestions": "Aucune suggestion pour ces filtres.",
|
||||||
|
"synonyms": "Synonymes proposés (un par ligne)",
|
||||||
|
"utterances": "Phrases proposées (une par ligne)",
|
||||||
|
"save": "Enregistrer",
|
||||||
|
"apply": "Appliquer",
|
||||||
|
"reject": "Rejeter",
|
||||||
|
"tab": {
|
||||||
|
"suggestions": "Suggestions",
|
||||||
|
"corrections": "Corrections brutes"
|
||||||
|
},
|
||||||
|
"filter": {
|
||||||
|
"status": "Statut",
|
||||||
|
"source": "Source",
|
||||||
|
"consumed": "Consommée",
|
||||||
|
"hasCorrected": "Technique corrigée",
|
||||||
|
"any": "Toutes",
|
||||||
|
"yes": "Oui",
|
||||||
|
"no": "Non"
|
||||||
|
},
|
||||||
|
"snippet": {
|
||||||
|
"title": "Snippet training_data.py",
|
||||||
|
"help": "Agrège les synonymes/phrases des suggestions « applied » d'une technique, au format à coller dans training_data.py.",
|
||||||
|
"keyPlaceholder": "clé de technique (ex. simmer)",
|
||||||
|
"generate": "Générer"
|
||||||
|
},
|
||||||
|
"retrain": {
|
||||||
|
"title": "Gate F1 + backfill",
|
||||||
|
"help": "Lance l'évaluation de régression F1 puis, si elle passe, recalcule les techniques de toutes les étapes.",
|
||||||
|
"run": "Lancer",
|
||||||
|
"running": "En cours…",
|
||||||
|
"passed": "OK — {{changed}}/{{total}} étape(s) recalculée(s)",
|
||||||
|
"failed": "Échec du gate — aucun backfill"
|
||||||
|
},
|
||||||
|
"col": {
|
||||||
|
"clause": "Clause",
|
||||||
|
"change": "Changement",
|
||||||
|
"created": "Créée",
|
||||||
|
"consumed": "Consommée"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"catalog": {
|
||||||
|
"title": "Ingrédients hors-catalogue",
|
||||||
|
"lead": "Ingrédients saisis en texte libre par les utilisateurs parce que le catalogue ne les couvrait pas. Regroupés par nom normalisé — à promouvoir dans reference-seed-data.ts + les locales, à la main.",
|
||||||
|
"empty": "Aucun ingrédient hors-catalogue.",
|
||||||
|
"tab": {
|
||||||
|
"pending": "À traiter",
|
||||||
|
"reviewed": "Traités"
|
||||||
|
},
|
||||||
|
"recipeCount": "{{count}} recette(s)",
|
||||||
|
"alsoWritten": "Aussi écrit : {{variants}}",
|
||||||
|
"seenIn": "Vu dans :",
|
||||||
|
"firstSeen": "Première fois le {{date}}",
|
||||||
|
"markReviewed": "Marquer comme traité",
|
||||||
|
"marking": "…",
|
||||||
|
"pruneOrphans": "Purger les orphelins",
|
||||||
|
"pruning": "Purge…",
|
||||||
|
"prunedNone": "Aucun placeholder orphelin à purger.",
|
||||||
|
"pruned": "{{count}} placeholder(s) orphelin(s) supprimé(s)."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
apps/admin-web/src/main.tsx
Normal file
24
apps/admin-web/src/main.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import { App } from "./App";
|
||||||
|
import { AdminAuthProvider } from "./features/auth/AdminAuthContext";
|
||||||
|
// Side-effect import: initializes i18next before anything renders.
|
||||||
|
import "./i18n/i18n";
|
||||||
|
// Global stylesheet (theme tokens + minimal reset) — the only non-colocated .scss import.
|
||||||
|
import "./styles/global.scss";
|
||||||
|
|
||||||
|
const rootElement = document.getElementById("root");
|
||||||
|
if (!rootElement) {
|
||||||
|
throw new Error("Root element not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(rootElement).render(
|
||||||
|
<StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<AdminAuthProvider>
|
||||||
|
<App />
|
||||||
|
</AdminAuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
169
apps/admin-web/src/pages/catalog/CatalogPage.tsx
Normal file
169
apps/admin-web/src/pages/catalog/CatalogPage.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
import type { CatalogPlaceholderGroupView } from "@batch-cooking/shared";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { adminApiClient } from "../../api/client";
|
||||||
|
import "../admin-page.scss";
|
||||||
|
import "./catalog-page.scss";
|
||||||
|
import { type CatalogTab, formatDate, reviewedParam, splitSpellings } from "./catalog";
|
||||||
|
|
||||||
|
type CatalogState =
|
||||||
|
| { status: "loading" }
|
||||||
|
| { status: "loaded"; groups: CatalogPlaceholderGroupView[] }
|
||||||
|
| { status: "error" };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Off-catalog ingredient review. Lists every placeholder `Ingredient` (the
|
||||||
|
* free text users typed when the seeded catalog fell short — see
|
||||||
|
* `Ingredient.isPlaceholder` in the API schema), grouped by normalized
|
||||||
|
* name, so a maintainer sees what the catalog is missing and how many
|
||||||
|
* recipes are waiting on it. Actions are deliberately minimal: mark a gap
|
||||||
|
* as handled, or purge rows no recipe references any more. Actually adding
|
||||||
|
* the catalog entry stays a manual edit of `reference-seed-data.ts` + the
|
||||||
|
* locale files.
|
||||||
|
*/
|
||||||
|
export function CatalogPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [tab, setTab] = useState<CatalogTab>("pending");
|
||||||
|
const [state, setState] = useState<CatalogState>({ status: "loading" });
|
||||||
|
const [pendingIds, setPendingIds] = useState<number[] | null>(null);
|
||||||
|
const [pruneMessage, setPruneMessage] = useState<string | null>(null);
|
||||||
|
const [isPruning, setIsPruning] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback((forTab: CatalogTab) => {
|
||||||
|
setState({ status: "loading" });
|
||||||
|
adminApiClient
|
||||||
|
.getPlaceholders(reviewedParam(forTab))
|
||||||
|
.then((groups) => setState({ status: "loaded", groups }))
|
||||||
|
.catch(() => setState({ status: "error" }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load(tab);
|
||||||
|
}, [tab, load]);
|
||||||
|
|
||||||
|
function markReviewed(group: CatalogPlaceholderGroupView) {
|
||||||
|
setPendingIds(group.ingredientIds);
|
||||||
|
adminApiClient
|
||||||
|
.markPlaceholdersReviewed({ ingredientIds: group.ingredientIds })
|
||||||
|
.then(() => load(tab))
|
||||||
|
.catch(() => load(tab))
|
||||||
|
.finally(() => setPendingIds(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneOrphans() {
|
||||||
|
setIsPruning(true);
|
||||||
|
setPruneMessage(null);
|
||||||
|
adminApiClient
|
||||||
|
.pruneOrphanPlaceholders()
|
||||||
|
.then(({ deleted }) => {
|
||||||
|
setPruneMessage(
|
||||||
|
deleted === 0
|
||||||
|
? t("admin.catalog.prunedNone")
|
||||||
|
: t("admin.catalog.pruned", { count: deleted }),
|
||||||
|
);
|
||||||
|
load(tab);
|
||||||
|
})
|
||||||
|
.catch(() => setPruneMessage(t("admin.common.loadError")))
|
||||||
|
.finally(() => setIsPruning(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-page">
|
||||||
|
<h1 className="admin-page__title">{t("admin.catalog.title")}</h1>
|
||||||
|
<p className="admin-page__lead">{t("admin.catalog.lead")}</p>
|
||||||
|
|
||||||
|
<div className="catalog-toolbar">
|
||||||
|
<div className="catalog-tabs" role="tablist">
|
||||||
|
{(["pending", "reviewed"] as const).map((value) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={tab === value}
|
||||||
|
className={tab === value ? "active" : undefined}
|
||||||
|
onClick={() => setTab(value)}
|
||||||
|
>
|
||||||
|
{t(`admin.catalog.tab.${value}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={pruneOrphans} disabled={isPruning}>
|
||||||
|
{isPruning ? t("admin.catalog.pruning") : t("admin.catalog.pruneOrphans")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{pruneMessage && <p className="catalog-prune-message">{pruneMessage}</p>}
|
||||||
|
|
||||||
|
{state.status === "loading" && (
|
||||||
|
<p className="admin-page__placeholder">{t("admin.common.loading")}</p>
|
||||||
|
)}
|
||||||
|
{state.status === "error" && (
|
||||||
|
<p className="admin-page__placeholder">{t("admin.common.loadError")}</p>
|
||||||
|
)}
|
||||||
|
{state.status === "loaded" &&
|
||||||
|
(state.groups.length === 0 ? (
|
||||||
|
<p className="admin-page__placeholder">{t("admin.catalog.empty")}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="catalog-list">
|
||||||
|
{state.groups.map((group) => (
|
||||||
|
<PlaceholderGroupCard
|
||||||
|
key={group.normalizedName}
|
||||||
|
group={group}
|
||||||
|
busy={pendingIds === group.ingredientIds}
|
||||||
|
onMarkReviewed={() => markReviewed(group)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlaceholderGroupCard({
|
||||||
|
group,
|
||||||
|
busy,
|
||||||
|
onMarkReviewed,
|
||||||
|
}: {
|
||||||
|
group: CatalogPlaceholderGroupView;
|
||||||
|
busy: boolean;
|
||||||
|
onMarkReviewed: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { headline, variants } = splitSpellings(group);
|
||||||
|
const firstSeen = formatDate(group.firstSeenAt);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="catalog-card">
|
||||||
|
<div className="catalog-card__head">
|
||||||
|
<h2 className="catalog-card__name">{headline}</h2>
|
||||||
|
<span className="catalog-card__count">
|
||||||
|
{t("admin.catalog.recipeCount", { count: group.recipeCount })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{variants.length > 0 && (
|
||||||
|
<p className="catalog-card__variants">
|
||||||
|
{t("admin.catalog.alsoWritten", { variants: variants.join(" · ") })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{group.sampleRecipes.length > 0 && (
|
||||||
|
<p className="catalog-card__recipes">
|
||||||
|
{t("admin.catalog.seenIn")} {group.sampleRecipes.map((recipe) => recipe.name).join(", ")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="catalog-card__foot">
|
||||||
|
{firstSeen && (
|
||||||
|
<span className="catalog-card__seen">
|
||||||
|
{t("admin.catalog.firstSeen", { date: firstSeen })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!group.allReviewed && (
|
||||||
|
<button type="button" onClick={onMarkReviewed} disabled={busy}>
|
||||||
|
{busy ? t("admin.catalog.marking") : t("admin.catalog.markReviewed")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -37,37 +37,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ingrédients / Techniques switch (issue #130) — sits above the
|
|
||||||
// pending/reviewed tabs; a bolder segmented control so the two levels read
|
|
||||||
// as distinct.
|
|
||||||
.catalog-kinds {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--space-xs);
|
|
||||||
margin-bottom: var(--space-sm);
|
|
||||||
|
|
||||||
button {
|
|
||||||
padding: var(--space-xs) var(--space-md);
|
|
||||||
font-family: var(--font-display);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
border-bottom: 2.5px solid transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
color: var(--color-primary);
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.catalog-tabs {
|
.catalog-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-xs);
|
gap: var(--space-xs);
|
||||||
margin-bottom: var(--space-md);
|
|
||||||
|
|
||||||
button {
|
button {
|
||||||
padding: var(--space-sm) var(--space-md);
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
|
@ -7,8 +7,8 @@ import {
|
||||||
} from "@batch-cooking/shared";
|
} from "@batch-cooking/shared";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { AdminApiError, adminApiClient } from "../../../api/admin-client";
|
import { ApiError, adminApiClient } from "../../api/client";
|
||||||
import { errorMessageService } from "../../../services/error-message.service";
|
import { errorMessageService } from "../../services/error-message.service";
|
||||||
import "../admin-page.scss";
|
import "../admin-page.scss";
|
||||||
import "./corrections-page.scss";
|
import "./corrections-page.scss";
|
||||||
import { linesToList, listsDiffer, listToLines } from "./corrections";
|
import { linesToList, listsDiffer, listToLines } from "./corrections";
|
||||||
|
|
@ -152,9 +152,7 @@ function SuggestionCard({
|
||||||
onMutated();
|
onMutated();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(
|
setError(
|
||||||
errorMessageService.getLabel(
|
errorMessageService.getLabel(err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR),
|
||||||
err instanceof AdminApiError ? err.code : ErrorCode.INTERNAL_ERROR,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
|
|
@ -234,9 +232,7 @@ function SnippetPanel() {
|
||||||
setSnippet(result.snippet);
|
setSnippet(result.snippet);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(
|
setError(
|
||||||
errorMessageService.getLabel(
|
errorMessageService.getLabel(err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR),
|
||||||
err instanceof AdminApiError ? err.code : ErrorCode.INTERNAL_ERROR,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -277,9 +273,7 @@ function RetrainPanel() {
|
||||||
setResult(await adminApiClient.retrain({}));
|
setResult(await adminApiClient.retrain({}));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(
|
setError(
|
||||||
errorMessageService.getLabel(
|
errorMessageService.getLabel(err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR),
|
||||||
err instanceof AdminApiError ? err.code : ErrorCode.INTERNAL_ERROR,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
|
|
@ -10,12 +10,12 @@ import {
|
||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { adminApiClient } from "../../../api/admin-client";
|
import { adminApiClient } from "../../api/client";
|
||||||
import "../admin-page.scss";
|
import "../admin-page.scss";
|
||||||
import "./dashboard-page.scss";
|
import "./dashboard-page.scss";
|
||||||
import { formatCount, kpiTiles, seriesTotal, shortDay } from "./dashboard";
|
import { formatCount, kpiTiles, seriesTotal, shortDay } from "./dashboard";
|
||||||
|
|
||||||
/** Load state for `GET /admin/api/metrics` — same discriminated-union shape as apps/web's page states. */
|
/** Load state for `GET /admin/metrics` — same discriminated-union shape as apps/web's page states. */
|
||||||
type DashboardState =
|
type DashboardState =
|
||||||
| { status: "loading" }
|
| { status: "loading" }
|
||||||
| { status: "loaded"; metrics: MetricsView }
|
| { status: "loaded"; metrics: MetricsView }
|
||||||
|
|
@ -26,7 +26,7 @@ const RANGE_DAYS = 30;
|
||||||
/**
|
/**
|
||||||
* Usage-metrics dashboard. KPI tiles from the snapshot, then a small line
|
* Usage-metrics dashboard. KPI tiles from the snapshot, then a small line
|
||||||
* chart per instrumented time series over the last {@link RANGE_DAYS} days.
|
* chart per instrumented time series over the last {@link RANGE_DAYS} days.
|
||||||
* Fed by `GET /admin/api/metrics`; read-only, refetched only on mount.
|
* Fed by `GET /admin/metrics`; read-only, refetched only on mount.
|
||||||
*/
|
*/
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
@ -2,20 +2,20 @@ import { adminLoginSchema, ErrorCode } from "@batch-cooking/shared";
|
||||||
import { type FormEvent, useState } from "react";
|
import { type FormEvent, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { AdminApiError } from "../../../api/admin-client";
|
import { ApiError } from "../../api/client";
|
||||||
import { useAdminAuth } from "../../../features/admin/AdminAuthContext";
|
import { useAdminAuth } from "../../features/auth/AdminAuthContext";
|
||||||
import "../../../features/admin/admin-auth.scss";
|
import "../../features/auth/admin-auth.scss";
|
||||||
import { fieldErrorsFrom } from "../../../lib/zod-errors";
|
import { fieldErrorsFrom } from "../../lib/zod-errors";
|
||||||
import { errorMessageService } from "../../../services/error-message.service";
|
import { errorMessageService } from "../../services/error-message.service";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The admin login screen — the only unauthenticated route under `/admin`.
|
* The admin login screen — the only unauthenticated route. Client-side
|
||||||
* Client-side validation via the shared `adminLoginSchema` (same rules the
|
* validation via the shared `adminLoginSchema` (same rules the API
|
||||||
* API enforces), then `POST /admin/api/auth/login`; any API failure is
|
* enforces), then `POST /admin/auth/login`; any API failure is translated
|
||||||
* translated to a localized label via {@link ErrorMessageService}. Same
|
* to a localized label via {@link ErrorMessageService}. Same structure as
|
||||||
* structure as the user-facing `LoginPage` (`pages/auth/`).
|
* apps/web's `LoginPage`.
|
||||||
*/
|
*/
|
||||||
export function AdminLoginPage() {
|
export function LoginPage() {
|
||||||
const { login } = useAdminAuth();
|
const { login } = useAdminAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
@ -40,9 +40,9 @@ export function AdminLoginPage() {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await login(result.data);
|
await login(result.data);
|
||||||
void navigate("/admin");
|
void navigate("/");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const code = err instanceof AdminApiError ? err.code : ErrorCode.INTERNAL_ERROR;
|
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
|
||||||
setFormError(errorMessageService.getLabel(code));
|
setFormError(errorMessageService.getLabel(code));
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
110
apps/admin-web/src/pages/monitoring/MonitoringPage.tsx
Normal file
110
apps/admin-web/src/pages/monitoring/MonitoringPage.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
import type { MonitoringView, ServiceHealthView } from "@batch-cooking/shared";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { adminApiClient } from "../../api/client";
|
||||||
|
import "../admin-page.scss";
|
||||||
|
import "./monitoring-page.scss";
|
||||||
|
import { clockTime, POLL_INTERVAL_MS, statusModifier } from "./monitoring";
|
||||||
|
|
||||||
|
type MonitoringState =
|
||||||
|
| { status: "loading" }
|
||||||
|
| { status: "loaded"; data: MonitoringView }
|
||||||
|
| { status: "error" };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Microservice health board. Fetches `GET /admin/monitoring` on mount and
|
||||||
|
* re-polls every {@link POLL_INTERVAL_MS} ms. One card per probed target
|
||||||
|
* (Postgres, API, intent-service, LLM worker), coloured by status.
|
||||||
|
*/
|
||||||
|
export function MonitoringPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [state, setState] = useState<MonitoringState>({ status: "loading" });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
adminApiClient
|
||||||
|
.getMonitoring()
|
||||||
|
.then((data) => {
|
||||||
|
if (!cancelled) setState({ status: "loaded", data });
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled)
|
||||||
|
setState((prev) => (prev.status === "loaded" ? prev : { status: "error" }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
load();
|
||||||
|
const timer = setInterval(load, POLL_INTERVAL_MS);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-page">
|
||||||
|
<h1 className="admin-page__title">{t("admin.monitoring.title")}</h1>
|
||||||
|
<p className="admin-page__lead">{t("admin.monitoring.lead")}</p>
|
||||||
|
|
||||||
|
{state.status === "loading" && (
|
||||||
|
<p className="admin-page__placeholder">{t("admin.common.loading")}</p>
|
||||||
|
)}
|
||||||
|
{state.status === "error" && (
|
||||||
|
<p className="admin-page__placeholder">{t("admin.common.loadError")}</p>
|
||||||
|
)}
|
||||||
|
{state.status === "loaded" && (
|
||||||
|
<>
|
||||||
|
<p className="monitoring-checked">
|
||||||
|
{t("admin.monitoring.lastChecked", { time: clockTime(state.data.generatedAt) })}
|
||||||
|
</p>
|
||||||
|
<div className="monitoring-grid">
|
||||||
|
{state.data.services.map((service) => (
|
||||||
|
<ServiceCard key={service.key} service={service} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ServiceCard({ service }: { service: ServiceHealthView }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<article className={`monitoring-card monitoring-card--${statusModifier(service.status)}`}>
|
||||||
|
<header className="monitoring-card__head">
|
||||||
|
<span className="monitoring-card__dot" aria-hidden="true" />
|
||||||
|
<h2>{t(`admin.monitoring.service.${service.key}`, { defaultValue: service.key })}</h2>
|
||||||
|
<span className="monitoring-card__status">
|
||||||
|
{t(`admin.monitoring.status.${service.status}`)}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<dl className="monitoring-card__meta">
|
||||||
|
{service.latencyMs !== null && (
|
||||||
|
<div>
|
||||||
|
<dt>{t("admin.monitoring.latency")}</dt>
|
||||||
|
<dd>{service.latencyMs} ms</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{service.detail && (
|
||||||
|
<div>
|
||||||
|
<dt>{t("admin.monitoring.detail")}</dt>
|
||||||
|
<dd>{service.detail}</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{service.lastRunAt !== undefined && (
|
||||||
|
<div>
|
||||||
|
<dt>{t("admin.monitoring.lastRun")}</dt>
|
||||||
|
<dd>
|
||||||
|
{service.lastRunAt ? clockTime(service.lastRunAt) : t("admin.monitoring.never")}
|
||||||
|
{service.lastResult?.job ? ` · ${service.lastResult.job}` : ""}
|
||||||
|
{service.lastResult?.ok === false ? ` · ${t("admin.monitoring.jobFailed")}` : ""}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -9,30 +9,6 @@
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shown above the board only while the WebSocket connection isn't
|
|
||||||
// currently up — the board itself always stays on screen underneath
|
|
||||||
// (see MonitoringPage.tsx's own comment on why `data` and
|
|
||||||
// `connectionState` are kept separate), this is just a heads-up that
|
|
||||||
// what's showing may be stale.
|
|
||||||
.monitoring-connection-badge {
|
|
||||||
display: inline-block;
|
|
||||||
margin: 0 0 var(--space-md);
|
|
||||||
padding: var(--space-xs) var(--space-sm);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
font-weight: 700;
|
|
||||||
|
|
||||||
&--reconnecting {
|
|
||||||
color: var(--color-warning);
|
|
||||||
background: color-mix(in srgb, var(--color-warning) 12%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
&--error {
|
|
||||||
color: var(--color-error);
|
|
||||||
background: color-mix(in srgb, var(--color-error) 12%, transparent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.monitoring-grid {
|
.monitoring-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
|
||||||
|
|
@ -10,6 +10,9 @@ export function statusModifier(status: ServiceStatus): string {
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How often the board re-polls `GET /admin/monitoring`, in ms. */
|
||||||
|
export const POLL_INTERVAL_MS = 15_000;
|
||||||
|
|
||||||
/** `"2026-08-28T09:00:00.000Z"` → `"09:00:00"` (local time) for the "last checked" line. */
|
/** `"2026-08-28T09:00:00.000Z"` → `"09:00:00"` (local time) for the "last checked" line. */
|
||||||
export function clockTime(iso: string): string {
|
export function clockTime(iso: string): string {
|
||||||
return new Date(iso).toLocaleTimeString("fr-FR");
|
return new Date(iso).toLocaleTimeString("fr-FR");
|
||||||
19
apps/admin-web/src/services/error-message.service.ts
Normal file
19
apps/admin-web/src/services/error-message.service.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { ErrorCode } from "@batch-cooking/shared";
|
||||||
|
import i18n from "../i18n/i18n";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Localized label for an {@link ErrorCode} returned by the admin API —
|
||||||
|
* verbatim behaviour of apps/web's `ErrorMessageService`: reverse-maps the
|
||||||
|
* numeric enum value to its member name (`4010` → `"INVALID_CREDENTIALS"`)
|
||||||
|
* and looks it up under the `errors` namespace, falling back to
|
||||||
|
* `INTERNAL_ERROR` for a code this client doesn't recognise.
|
||||||
|
*/
|
||||||
|
export class ErrorMessageService {
|
||||||
|
public getLabel(code: ErrorCode): string {
|
||||||
|
const memberName = ErrorCode[code] ?? ErrorCode[ErrorCode.INTERNAL_ERROR];
|
||||||
|
return i18n.t(`errors.${memberName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Single shared instance — stateless. */
|
||||||
|
export const errorMessageService = new ErrorMessageService();
|
||||||
171
apps/admin-web/src/styles/_theme.scss
Normal file
171
apps/admin-web/src/styles/_theme.scss
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
// NOTE: verbatim copy of apps/web/src/styles/_theme.scss. Extracting these
|
||||||
|
// tokens into a shared package (consumed by both apps) is tracked separately
|
||||||
|
// — keep the two files in sync by hand until then.
|
||||||
|
// =============================================================================
|
||||||
|
// Design tokens — the single source of truth for colors, spacing, typography
|
||||||
|
// and other reusable values across the whole app.
|
||||||
|
//
|
||||||
|
// Exposed as CSS custom properties on :root (not plain SCSS variables) so
|
||||||
|
// they're available at *runtime*, not just compile time — this is what lets
|
||||||
|
// dark mode work below by simply redefining these variables instead of
|
||||||
|
// rebuilding the stylesheet. Every other .scss file should reference
|
||||||
|
// `var(--token-name)`, never a hardcoded color/size.
|
||||||
|
//
|
||||||
|
// Palette name: "Mise en Place" — a kitchen-operations identity (batch
|
||||||
|
// cooking as logistics: everything labeled and in its place before you
|
||||||
|
// start) rather than a food-blog one. See the design proposal for the full
|
||||||
|
// rationale: https://claude.ai/code/artifact/1db63af0-cfd1-4f77-9369-71ca6accd06f
|
||||||
|
//
|
||||||
|
// Import this partial once, globally (see global.scss) — never re-import it
|
||||||
|
// from a component-level .scss file, `:root` only needs to be declared once.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
:root {
|
||||||
|
// --- Surfaces & ink ---------------------------------------------------
|
||||||
|
// Neutral surface: page background ("porcelaine") vs. the card surface
|
||||||
|
// content sits on, plus a recessed variant for panels/table headers.
|
||||||
|
--color-background: #eef2ed;
|
||||||
|
--color-surface: #ffffff;
|
||||||
|
--color-surface-alt: #e2e8e0;
|
||||||
|
// Text.
|
||||||
|
--color-text: #1f2a22;
|
||||||
|
--color-text-muted: #57685a;
|
||||||
|
--color-border: #c7d0c4;
|
||||||
|
|
||||||
|
// --- Brand accents, each with one job --------------------------------
|
||||||
|
// Basil — primary actions, links, brand presence.
|
||||||
|
--color-primary: #2e6b4a;
|
||||||
|
--color-primary-hover: #244f38;
|
||||||
|
// Vermillion — secondary accent for urgency/strong calls to action
|
||||||
|
// (e.g. a timer, "start session"). Never reused for allergen alerts
|
||||||
|
// below — those need their own, unambiguous color.
|
||||||
|
--color-accent: #cc4b26;
|
||||||
|
--color-accent-hover: #a83c1c;
|
||||||
|
// Turmeric — category/classification tags.
|
||||||
|
--color-tag: #c98a1b;
|
||||||
|
--color-tag-ink: #3a2c05; // pairs with a solid --color-tag fill only.
|
||||||
|
|
||||||
|
// --- Feedback -----------------------------------------------------------
|
||||||
|
--color-success: #2e6b4a;
|
||||||
|
--color-warning: #c98a1b;
|
||||||
|
--color-error: #b3271e;
|
||||||
|
|
||||||
|
// --- Allergens / intolerances --------------------------------------------
|
||||||
|
// A 3-tier food-safety scale, kept distinct from --color-error so an
|
||||||
|
// allergen warning is never confused with a form validation error:
|
||||||
|
// - critical (declared allergen): its own color, solid/inverted fill
|
||||||
|
// - moderate (intolerance): reuses --color-warning, tinted fill
|
||||||
|
// - trace ("may contain traces of…"): neutral, dashed outline
|
||||||
|
// The severity is carried by the FILL TREATMENT, not the hue alone, so
|
||||||
|
// it stays legible for color-blind users. See the design proposal's
|
||||||
|
// "Alertes & allergènes" section for the full component set.
|
||||||
|
--color-allergen: #a8123f;
|
||||||
|
--color-allergen-ink: #ffe9ef; // pairs with a solid --color-allergen fill only.
|
||||||
|
|
||||||
|
// --- Spacing scale ---------------------------------------------------------
|
||||||
|
// Multiples of a 4px base unit — use these instead of ad hoc px values so
|
||||||
|
// spacing stays visually consistent as the app grows.
|
||||||
|
--space-xs: 0.25rem; // 4px
|
||||||
|
--space-sm: 0.5rem; // 8px
|
||||||
|
--space-md: 1rem; // 16px
|
||||||
|
--space-lg: 1.5rem; // 24px
|
||||||
|
--space-xl: 2rem; // 32px
|
||||||
|
--space-2xl: 3rem; // 48px — inter-section spacing
|
||||||
|
|
||||||
|
// --- Typography --------------------------------------------------------
|
||||||
|
// System font stacks only — no remote webfont, so there's zero loading
|
||||||
|
// latency and no flash of unstyled text, which fits an app meant to be
|
||||||
|
// used quickly under time pressure. Three roles: a condensed "label"
|
||||||
|
// face for headings/eyebrows, a humanist face for body copy, and a
|
||||||
|
// monospace for anything that lines up in columns (times, quantities).
|
||||||
|
--font-display: "Bahnschrift", "Arial Narrow", "Segoe UI", sans-serif;
|
||||||
|
--font-body: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
|
||||||
|
--font-mono: "Cascadia Mono", Consolas, "SF Mono", "Liberation Mono", monospace;
|
||||||
|
|
||||||
|
--font-size-xs: 0.75rem; // 12px — captions, meta
|
||||||
|
--font-size-sm: 0.875rem; // 14px — labels, secondary text
|
||||||
|
--font-size-base: 1rem; // 16px — body
|
||||||
|
--font-size-md: 1.125rem; // 18px — lead paragraph
|
||||||
|
--font-size-lg: 1.375rem; // 22px — H3 / card titles
|
||||||
|
--font-size-xl: 1.75rem; // 28px — H2 / section titles
|
||||||
|
--font-size-2xl: 2.25rem; // 36px — H1 / page titles
|
||||||
|
--font-size-3xl: 3rem; // 48px — display, exceptional use only
|
||||||
|
|
||||||
|
// --- Shape / elevation --------------------------------------------------
|
||||||
|
--radius-base: 4px; // controls (inputs, buttons) — deliberately flat
|
||||||
|
--radius-md: 10px; // cards
|
||||||
|
--radius-lg: 18px; // panels, modals
|
||||||
|
--radius-pill: 999px; // tags, badges
|
||||||
|
--max-width-form: 22rem;
|
||||||
|
|
||||||
|
--shadow-sm: 0 1px 2px rgba(31, 42, 34, 0.08), 0 1px 1px rgba(31, 42, 34, 0.06);
|
||||||
|
--shadow-md: 0 6px 16px rgba(31, 42, 34, 0.12), 0 2px 6px rgba(31, 42, 34, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lets the browser pick sensible default colors (form controls, scrollbars)
|
||||||
|
// for whichever mode the user ends up in.
|
||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Dark mode ---------------------------------------------------------
|
||||||
|
// Follows the OS/browser preference by default. Guarded with
|
||||||
|
// `:root:not([data-theme="light"])` so an explicit "light" choice (see
|
||||||
|
// apps/web's `ThemeContext`, `SYSTEM` = no `data-theme` attribute at all —
|
||||||
|
// this block then decides) can override a dark OS setting.
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not([data-theme="light"]) {
|
||||||
|
--color-background: #14181a;
|
||||||
|
--color-surface: #1c221e;
|
||||||
|
--color-surface-alt: #262e27;
|
||||||
|
--color-text: #edf1ea;
|
||||||
|
--color-text-muted: #a9b5a6;
|
||||||
|
--color-border: #384038;
|
||||||
|
|
||||||
|
--color-primary: #5fae7e;
|
||||||
|
--color-primary-hover: #7cc496;
|
||||||
|
--color-accent: #ea7a48;
|
||||||
|
--color-accent-hover: #f0946c;
|
||||||
|
--color-tag: #e8b84b;
|
||||||
|
--color-tag-ink: #2a2005;
|
||||||
|
|
||||||
|
--color-success: #5fae7e;
|
||||||
|
--color-warning: #e8b84b;
|
||||||
|
--color-error: #e5675a;
|
||||||
|
|
||||||
|
--color-allergen: #e2547b;
|
||||||
|
--color-allergen-ink: #3a0416;
|
||||||
|
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35);
|
||||||
|
--shadow-md: 0 8px 20px rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors the block above for an explicit "dark" choice (`ThemeContext`
|
||||||
|
// sets `data-theme="dark"` on `<html>`), so it wins over the OS setting in
|
||||||
|
// both directions.
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
--color-background: #14181a;
|
||||||
|
--color-surface: #1c221e;
|
||||||
|
--color-surface-alt: #262e27;
|
||||||
|
--color-text: #edf1ea;
|
||||||
|
--color-text-muted: #a9b5a6;
|
||||||
|
--color-border: #384038;
|
||||||
|
|
||||||
|
--color-primary: #5fae7e;
|
||||||
|
--color-primary-hover: #7cc496;
|
||||||
|
--color-accent: #ea7a48;
|
||||||
|
--color-accent-hover: #f0946c;
|
||||||
|
--color-tag: #e8b84b;
|
||||||
|
--color-tag-ink: #2a2005;
|
||||||
|
|
||||||
|
--color-success: #5fae7e;
|
||||||
|
--color-warning: #e8b84b;
|
||||||
|
--color-error: #e5675a;
|
||||||
|
|
||||||
|
--color-allergen: #e2547b;
|
||||||
|
--color-allergen-ink: #3a0416;
|
||||||
|
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35);
|
||||||
|
--shadow-md: 0 8px 20px rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
149
apps/admin-web/src/styles/global.scss
Normal file
149
apps/admin-web/src/styles/global.scss
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
// =============================================================================
|
||||||
|
// Global stylesheet — imported exactly once, in main.tsx. Contains only
|
||||||
|
// truly app-wide rules: the theme tokens and a minimal reset/base styling
|
||||||
|
// that every page inherits. Anything specific to one component or page
|
||||||
|
// belongs in a .scss file colocated next to that component/page instead.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
@use "./theme";
|
||||||
|
|
||||||
|
// Include borders/padding in an element's declared width/height everywhere,
|
||||||
|
// rather than the browser default of adding them on top.
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal reset: remove the default body margin so pages can control their
|
||||||
|
// own layout without fighting the browser's default 8px margin.
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--color-text);
|
||||||
|
background: var(--color-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Headings use the condensed "label" face app-wide — see _theme.scss for
|
||||||
|
// the rationale. `text-wrap: balance` avoids a lone short word wrapping
|
||||||
|
// onto its own line in multi-line titles.
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 700;
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to the page-title size; a heading used as a smaller component
|
||||||
|
// title (e.g. the auth card's <h1>) overrides this in its own stylesheet.
|
||||||
|
h1 {
|
||||||
|
font-size: var(--font-size-2xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A visible, consistent focus ring for keyboard navigation — the browser
|
||||||
|
// default varies a lot between elements and browsers.
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checkbox/radio appearance, app-wide — "selectable card" style: the native
|
||||||
|
// control itself is visually hidden (still real, focusable and
|
||||||
|
// screen-reader-visible — see the `input[type=...]` rule below, not
|
||||||
|
// `display: none`) and the whole label row it lives in becomes the
|
||||||
|
// interactive surface instead: a flat bordered box that fills in with a
|
||||||
|
// tinted background + primary border once selected, with a checkmark
|
||||||
|
// fading in on the leading edge.
|
||||||
|
//
|
||||||
|
// The base (unselected) look below is detected structurally with `:has()`
|
||||||
|
// — safe, since "does this label contain a checkbox/radio" never changes
|
||||||
|
// after mount. The *selected* look is instead driven by the `is-selected`
|
||||||
|
// class {@link CheckboxOption}/{@link RadioOption} (components/ui/) toggle
|
||||||
|
// in JS from the same boolean their caller already passes to `checked` —
|
||||||
|
// chaining a second `:has(:checked)` to react to that live state turned
|
||||||
|
// out to be unreliable across browsers, so this only needs one
|
||||||
|
// always-true `:has()`.
|
||||||
|
//
|
||||||
|
// Every checkbox/radio in the app goes through this one place (the allergy
|
||||||
|
// grid, the theme picker, anywhere future) rather than each feature styling
|
||||||
|
// its own — see profile-forms.scss / settings-pages.scss, which only
|
||||||
|
// arrange these within their own layout (grid vs. stacked list) and
|
||||||
|
// intentionally don't re-style the control/label look itself.
|
||||||
|
label:has(> input[type="checkbox"]),
|
||||||
|
label:has(> input[type="radio"]) {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
padding: var(--space-xs) var(--space-sm);
|
||||||
|
// Overrides the generic `label { font-weight: 600 }` base rule
|
||||||
|
// (profile-forms.scss) — without this, an *unselected* row reads just as
|
||||||
|
// bold as a selected one (only `.allergy-select__option` happened to set
|
||||||
|
// its own 400 already; `.theme-select__option` didn't, so its rows were
|
||||||
|
// all permanently bold until this was centralized here).
|
||||||
|
font-weight: 400;
|
||||||
|
border: 1.5px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
background: var(--color-surface);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background-color 0.15s ease,
|
||||||
|
border-color 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-selected {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 12%, var(--color-surface));
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:has(:focus-visible) {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The control itself is removed from the visual flow — hidden the
|
||||||
|
// "sr-only" way (not `display: none`) so it stays focusable/tabbable and
|
||||||
|
// announced correctly by screen readers; the label above carries the
|
||||||
|
// entire visible selected/unchecked look.
|
||||||
|
input[type="checkbox"],
|
||||||
|
input[type="radio"] {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
margin: 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The checkmark — a real element (see components/ui/Checkbox.tsx /
|
||||||
|
// Radio.tsx) shown via the same `is-selected` class as the label's own
|
||||||
|
// look above, not a separate CSS-only trigger. Scaled in from nothing so
|
||||||
|
// toggling has a bit of motion. Same mark for both checkbox and radio: one
|
||||||
|
// consistent "selected" language app-wide rather than a checkmark here and
|
||||||
|
// a dot there. Sits first in the row (before the label text, per DOM
|
||||||
|
// order) — a classic "control on the left" layout rather than trailing.
|
||||||
|
.check-mark {
|
||||||
|
flex: none;
|
||||||
|
width: 0.9rem;
|
||||||
|
height: 0.9rem;
|
||||||
|
background: var(--color-primary);
|
||||||
|
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
|
||||||
|
transform: scale(0);
|
||||||
|
transition: transform 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-selected .check-mark {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
5
apps/admin-web/src/vite-env.d.ts
vendored
Normal file
5
apps/admin-web/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
// Injected by `define` in vite.config.ts, sourced from package.json's
|
||||||
|
// version field — rendered in AdminLayout.tsx.
|
||||||
|
declare const __APP_VERSION__: string;
|
||||||
14
apps/admin-web/tsconfig.app.json
Normal file
14
apps/admin-web/tsconfig.app.json
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"noEmit": true,
|
||||||
|
"composite": true,
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
8
apps/admin-web/tsconfig.json
Normal file
8
apps/admin-web/tsconfig.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler"
|
||||||
|
},
|
||||||
|
"files": [],
|
||||||
|
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
12
apps/admin-web/tsconfig.node.json
Normal file
12
apps/admin-web/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"composite": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
19
apps/admin-web/vite.config.ts
Normal file
19
apps/admin-web/vite.config.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
// Read once at config-eval time — same trick as apps/web's vite.config.ts.
|
||||||
|
const pkg = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf-8"));
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: { port: 5174 },
|
||||||
|
css: {
|
||||||
|
preprocessorOptions: {
|
||||||
|
scss: { api: "modern-compiler" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
define: {
|
||||||
|
__APP_VERSION__: JSON.stringify(pkg.version),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -11,7 +11,7 @@ JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
||||||
# Optional — defaults shown (see src/config/env.ts)
|
# Optional — defaults shown (see src/config/env.ts)
|
||||||
# JWT_EXPIRES_IN=7d
|
# JWT_EXPIRES_IN=7d
|
||||||
# AUTH_COOKIE_NAME=session
|
# AUTH_COOKIE_NAME=session
|
||||||
# CORS_ORIGIN=http://localhost:5173,https://localhost
|
# CORS_ORIGIN=http://localhost:5173
|
||||||
# INTENT_SERVICE_BASE_URL=http://localhost:8000
|
# INTENT_SERVICE_BASE_URL=http://localhost:8000
|
||||||
|
|
||||||
# Required — services/tech-step-intent-service must be running locally (see
|
# Required — services/tech-step-intent-service must be running locally (see
|
||||||
|
|
@ -25,22 +25,3 @@ INTENT_SERVICE_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
||||||
# Generate your own the same way as JWT_SECRET above; must match the
|
# Generate your own the same way as JWT_SECRET above; must match the
|
||||||
# worker's own INTERNAL_WORKER_SECRET.
|
# worker's own INTERNAL_WORKER_SECRET.
|
||||||
# INTERNAL_WORKER_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
# INTERNAL_WORKER_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
||||||
|
|
||||||
# Only needed to use the admin surface (apps/web's /admin/* routes) — every
|
|
||||||
# /admin/* request 401s while unset (`requireAdmin` fails closed). MUST be a
|
|
||||||
# different value than JWT_SECRET. Generate your own the same way.
|
|
||||||
# ADMIN_JWT_SECRET=changeme-a-different-random-secret-at-least-32-chars
|
|
||||||
# Read only by `src/scripts/create-admin.ts` when its --email/--password/
|
|
||||||
# --name flags are omitted — never by the running server.
|
|
||||||
# ADMIN_INITIAL_EMAIL=ops@example.com
|
|
||||||
# ADMIN_INITIAL_PASSWORD=changeme-at-least-8-chars
|
|
||||||
# ADMIN_INITIAL_NAME=Ops
|
|
||||||
|
|
||||||
# Only needed to create a Forgejo issue from a contact message
|
|
||||||
# (apps/web's /admin/messages, issue #81). The button is hidden and the
|
|
||||||
# endpoint answers 503 while any of the three is unset. FORGEJO_TOKEN is a
|
|
||||||
# personal access token with the write:issue scope — generate it in Forgejo,
|
|
||||||
# never commit the real value.
|
|
||||||
# FORGEJO_BASE_URL=https://git.example.com
|
|
||||||
# FORGEJO_TOKEN=changeme-a-forgejo-personal-access-token
|
|
||||||
# FORGEJO_REPO=owner/repo
|
|
||||||
|
|
|
||||||
|
|
@ -60,11 +60,8 @@ COPY --from=build /repo/apps/web/dist ./apps/web/dist
|
||||||
|
|
||||||
WORKDIR /repo/apps/api
|
WORKDIR /repo/apps/api
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
# Applies pending migrations, seeds the reference data (Diet/Category/
|
# Applies pending migrations, then seeds the reference data (Diet/Category/
|
||||||
# Allergy — see src/scripts/seed-runtime.ts), then runs any pending one-time
|
# Allergy — see src/scripts/seed-runtime.ts) before starting. Both steps are
|
||||||
# data backfill (src/scripts/backfill-runtime.ts) before starting. migrate
|
# safe to repeat on every container start: migrate deploy only applies
|
||||||
# deploy only applies pending migrations and the seed upserts by unique
|
# pending migrations, and the seed upserts by unique name.
|
||||||
# name, so both are safe to repeat every start; the backfill step gates
|
CMD ["sh", "-c", "node_modules/.bin/prisma migrate deploy && node dist/scripts/seed-runtime.js && node dist/server.js"]
|
||||||
# itself on a `backfill_state` row (no-op once done) and is written never to
|
|
||||||
# fail the boot — see its doc comment.
|
|
||||||
CMD ["sh", "-c", "node_modules/.bin/prisma migrate deploy && node dist/scripts/seed-runtime.js && node dist/scripts/backfill-runtime.js && node dist/server.js"]
|
|
||||||
|
|
|
||||||
|
|
@ -23,12 +23,10 @@
|
||||||
"@batch-cooking/shared": "workspace:*",
|
"@batch-cooking/shared": "workspace:*",
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
"argon2": "0.31.2",
|
"argon2": "0.31.2",
|
||||||
"cookie": "^2.0.1",
|
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.21.1",
|
"express": "^4.21.1",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"prisma": "^5.22.0",
|
"prisma": "^5.22.0",
|
||||||
"socket.io": "^4.8.3",
|
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -40,7 +38,6 @@
|
||||||
"chai": "^5.1.2",
|
"chai": "^5.1.2",
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "^10.1.0",
|
||||||
"mocha": "^10.8.2",
|
"mocha": "^10.8.2",
|
||||||
"socket.io-client": "^4.8.3",
|
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.2"
|
||||||
|
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
-- CreateTable: temperatures mentioned in the same clause as a detected
|
|
||||||
-- technique — regex-extracted (services/tech-step-intent-service), no
|
|
||||||
-- foreign key of its own (the structured value is the data). Same
|
|
||||||
-- composite FK + ON DELETE CASCADE shape as step_tech_step_ingredient /
|
|
||||||
-- step_tech_step_utensil (see 20260826121000_step_tech_step_metadata).
|
|
||||||
CREATE TABLE "step_tech_step_temperature" (
|
|
||||||
"id" SERIAL NOT NULL,
|
|
||||||
"step_id" INTEGER NOT NULL,
|
|
||||||
"tech_step_order" INTEGER NOT NULL,
|
|
||||||
"start" INTEGER NOT NULL,
|
|
||||||
"end" INTEGER NOT NULL,
|
|
||||||
"celsius" DECIMAL(6,2),
|
|
||||||
"gas_mark" INTEGER,
|
|
||||||
"qualitative" TEXT,
|
|
||||||
"raw" TEXT NOT NULL,
|
|
||||||
"source" TEXT NOT NULL DEFAULT 'auto',
|
|
||||||
|
|
||||||
CONSTRAINT "step_tech_step_temperature_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "step_tech_step_temperature" ADD CONSTRAINT "step_tech_step_temperature_step_id_tech_step_order_fkey" FOREIGN KEY ("step_id", "tech_step_order") REFERENCES "step_tech_step"("step_id", "order") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
-- CreateTable: idempotency ledger for one-time data backfills run from the
|
|
||||||
-- production image's start command (apps/api/Dockerfile CMD ->
|
|
||||||
-- src/scripts/backfill-runtime.ts). One row per completed backfill, keyed
|
|
||||||
-- by an opaque backfill name; presence of the row is the "already done,
|
|
||||||
-- don't repeat" signal. See the `BackfillState` model doc comment.
|
|
||||||
CREATE TABLE "backfill_state" (
|
|
||||||
"key" TEXT NOT NULL,
|
|
||||||
"completed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT "backfill_state_pkey" PRIMARY KEY ("key")
|
|
||||||
);
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
-- Splits "portions to prepare" from "portions actually needed" on a
|
|
||||||
-- planning slot, and adds the two columns a leftovers-serving item needs
|
|
||||||
-- (its source item + how much of its own pool was thrown away). Purely
|
|
||||||
-- additive — no existing column renamed. See the `PlanningItem` model doc
|
|
||||||
-- comments and issue #34.
|
|
||||||
|
|
||||||
-- `discarded_portions` and `source_planning_item_id` are trivial adds.
|
|
||||||
ALTER TABLE "planning_item"
|
|
||||||
ADD COLUMN "discarded_portions" INTEGER NOT NULL DEFAULT 0,
|
|
||||||
ADD COLUMN "source_planning_item_id" INTEGER;
|
|
||||||
|
|
||||||
-- `portions_requested` is NOT NULL but the table may have rows already:
|
|
||||||
-- add it nullable, backfill, then lock it down — same discipline as the
|
|
||||||
-- `ingredient_unit_catalog` / `planning_item_portions` migrations. Every
|
|
||||||
-- pre-existing row predates this feature, so it had no leftovers:
|
|
||||||
-- requested == prepared at the time it was added.
|
|
||||||
ALTER TABLE "planning_item" ADD COLUMN "portions_requested" INTEGER;
|
|
||||||
UPDATE "planning_item" SET "portions_requested" = "portions";
|
|
||||||
ALTER TABLE "planning_item" ALTER COLUMN "portions_requested" SET NOT NULL;
|
|
||||||
|
|
||||||
-- Self-FK for a "leftovers" item pointing back at the item it draws from.
|
|
||||||
-- ON DELETE CASCADE: removing the source item removes everything served
|
|
||||||
-- from its pool too (same as the Planning -> PlanningItem cascade).
|
|
||||||
ALTER TABLE "planning_item"
|
|
||||||
ADD CONSTRAINT "planning_item_source_planning_item_id_fkey"
|
|
||||||
FOREIGN KEY ("source_planning_item_id") REFERENCES "planning_item"("id")
|
|
||||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
-- Guided-tour scenarios, their steps, the catalogue of valid highlight
|
|
||||||
-- targets, and per-user "seen" progress. All four tables are new — plain
|
|
||||||
-- CREATEs, nothing to backfill. See issues #42 (the tour engine) and #44
|
|
||||||
-- (admin CRUD + the DB-backed model this migration implements, which
|
|
||||||
-- revises #42's originally-planned in-code registry).
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "tour_target" (
|
|
||||||
"key" TEXT NOT NULL,
|
|
||||||
"description" TEXT NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "tour_target_pkey" PRIMARY KEY ("key")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "tour_scenario" (
|
|
||||||
"id" SERIAL NOT NULL,
|
|
||||||
"key" TEXT NOT NULL,
|
|
||||||
"name" TEXT NOT NULL,
|
|
||||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "tour_scenario_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "tour_scenario_step" (
|
|
||||||
"id" SERIAL NOT NULL,
|
|
||||||
"scenario_id" INTEGER NOT NULL,
|
|
||||||
"order" INTEGER NOT NULL,
|
|
||||||
"route" TEXT,
|
|
||||||
"target_key" TEXT NOT NULL,
|
|
||||||
"title" TEXT NOT NULL,
|
|
||||||
"content" TEXT NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "tour_scenario_step_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "tour_progress" (
|
|
||||||
"user_profile_id" INTEGER NOT NULL,
|
|
||||||
"scenario_id" INTEGER NOT NULL,
|
|
||||||
"completed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT "tour_progress_pkey" PRIMARY KEY ("user_profile_id","scenario_id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "tour_scenario_key_key" ON "tour_scenario"("key");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "tour_scenario_step_scenario_id_order_key" ON "tour_scenario_step"("scenario_id", "order");
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "tour_scenario_step" ADD CONSTRAINT "tour_scenario_step_scenario_id_fkey" FOREIGN KEY ("scenario_id") REFERENCES "tour_scenario"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "tour_scenario_step" ADD CONSTRAINT "tour_scenario_step_target_key_fkey" FOREIGN KEY ("target_key") REFERENCES "tour_target"("key") ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "tour_progress" ADD CONSTRAINT "tour_progress_user_profile_id_fkey" FOREIGN KEY ("user_profile_id") REFERENCES "user_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "tour_progress" ADD CONSTRAINT "tour_progress_scenario_id_fkey" FOREIGN KEY ("scenario_id") REFERENCES "tour_scenario"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
-- Contact-form messages (issue #80). New table only — nothing to backfill.
|
|
||||||
-- `author_id` is nullable with ON DELETE SET NULL so a report survives its
|
|
||||||
-- sender's account deletion (same as ingredients.created_by_id).
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "contact_message" (
|
|
||||||
"id" SERIAL NOT NULL,
|
|
||||||
"author_id" INTEGER,
|
|
||||||
"type" TEXT NOT NULL,
|
|
||||||
"message" TEXT NOT NULL,
|
|
||||||
"page_url" TEXT,
|
|
||||||
"user_agent" TEXT,
|
|
||||||
"app_version" TEXT,
|
|
||||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT "contact_message_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "contact_message_created_at_idx" ON "contact_message"("created_at");
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "contact_message" ADD CONSTRAINT "contact_message_author_id_fkey" FOREIGN KEY ("author_id") REFERENCES "user_profiles"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
-- Link a contact message to the Forgejo issue an admin created from it
|
|
||||||
-- (issue #81). Two nullable columns, no backfill — existing rows have no
|
|
||||||
-- linked issue.
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "contact_message"
|
|
||||||
ADD COLUMN "forgejo_issue_number" INTEGER,
|
|
||||||
ADD COLUMN "forgejo_issue_url" TEXT;
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
-- Ingrédients d'assaisonnement « selon le goût » (issue #128, puce 1 de #3) :
|
|
||||||
-- une ligne de recette peut désormais n'avoir ni quantité ni unité. Purement
|
|
||||||
-- additif — toutes les lignes existantes gardent leurs valeurs, aucune n'est
|
|
||||||
-- réécrite.
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "recipe_ingredient" ALTER COLUMN "quantity" DROP NOT NULL;
|
|
||||||
ALTER TABLE "recipe_ingredient" ALTER COLUMN "unit_id" DROP NOT NULL;
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
-- Régimes d'une recette calculés depuis ses ingrédients (issue #131, puce 4
|
|
||||||
-- de #3) — `RecipeDiet` (tag manuel) est supprimé : `RecipeSummaryView.diets`
|
|
||||||
-- est désormais dérivé de `IngredientDiet` + de l'allergène `gluten` côté
|
|
||||||
-- lecture (voir `computeRecipeDietViews`). Aucune donnée à conserver.
|
|
||||||
|
|
||||||
-- DropTable
|
|
||||||
DROP TABLE "recipe_diet";
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
-- AlterTable: off-catalog technique "placeholder" rows (issue #130, puce 3
|
|
||||||
-- de #3) — a user names a technique the catalog is missing while correcting a
|
|
||||||
-- step. Every existing row is a real seeded catalog entry, so the flag
|
|
||||||
-- defaults to false and the four new nullable columns stay NULL for them —
|
|
||||||
-- no backfill needed. Mirrors the `ingredients` placeholder migration.
|
|
||||||
ALTER TABLE "tech_step" ADD COLUMN "is_placeholder" BOOLEAN NOT NULL DEFAULT false;
|
|
||||||
ALTER TABLE "tech_step" ADD COLUMN "display_name" TEXT;
|
|
||||||
ALTER TABLE "tech_step" ADD COLUMN "created_by_id" INTEGER;
|
|
||||||
ALTER TABLE "tech_step" ADD COLUMN "created_at" TIMESTAMP(3);
|
|
||||||
ALTER TABLE "tech_step" ADD COLUMN "reviewed_at" TIMESTAMP(3);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "tech_step_is_placeholder_idx" ON "tech_step"("is_placeholder");
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "tech_step" ADD CONSTRAINT "tech_step_created_by_id_fkey" FOREIGN KEY ("created_by_id") REFERENCES "user_profiles"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
|
|
@ -49,6 +49,7 @@ model Diet {
|
||||||
key String @unique
|
key String @unique
|
||||||
|
|
||||||
users UserProfile[]
|
users UserProfile[]
|
||||||
|
recipes RecipeDiet[]
|
||||||
ingredients IngredientDiet[]
|
ingredients IngredientDiet[]
|
||||||
|
|
||||||
@@map("diet")
|
@@map("diet")
|
||||||
|
|
@ -124,9 +125,6 @@ model UserProfile {
|
||||||
/// list regardless of that real-world cardinality.
|
/// list regardless of that real-world cardinality.
|
||||||
administeredHouses House[] @relation("HouseAdmin")
|
administeredHouses House[] @relation("HouseAdmin")
|
||||||
preferences UserPreference?
|
preferences UserPreference?
|
||||||
/// Guided-tour scenarios this profile has seen (completed or skipped) —
|
|
||||||
/// see {@link TourProgress} and issues #42 / #44.
|
|
||||||
tourProgress TourProgress[]
|
|
||||||
/// Tech-step corrections this profile has submitted (any profile that can
|
/// Tech-step corrections this profile has submitted (any profile that can
|
||||||
/// view a recipe may correct its tech-step matches, not just its author —
|
/// view a recipe may correct its tech-step matches, not just its author —
|
||||||
/// see `StepTechStepCorrection.correctorId`).
|
/// see `StepTechStepCorrection.correctorId`).
|
||||||
|
|
@ -134,11 +132,6 @@ model UserProfile {
|
||||||
/// Placeholder `Ingredient` rows this profile created by typing a free-text
|
/// Placeholder `Ingredient` rows this profile created by typing a free-text
|
||||||
/// ingredient the catalog didn't cover — see `Ingredient.isPlaceholder`.
|
/// ingredient the catalog didn't cover — see `Ingredient.isPlaceholder`.
|
||||||
createdIngredientPlaceholders Ingredient[] @relation("PlaceholderCreator")
|
createdIngredientPlaceholders Ingredient[] @relation("PlaceholderCreator")
|
||||||
/// Placeholder `TechStep` rows this profile proposed while correcting a step
|
|
||||||
/// — see `TechStep.isPlaceholder`.
|
|
||||||
createdTechStepPlaceholders TechStep[] @relation("TechStepPlaceholderCreator")
|
|
||||||
/// Contact-form messages this profile has sent — see {@link ContactMessage}.
|
|
||||||
contactMessages ContactMessage[]
|
|
||||||
|
|
||||||
@@map("user_profiles")
|
@@map("user_profiles")
|
||||||
}
|
}
|
||||||
|
|
@ -220,41 +213,17 @@ model Planning {
|
||||||
}
|
}
|
||||||
|
|
||||||
model PlanningItem {
|
model PlanningItem {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
planningId Int @map("planning_id")
|
planningId Int @map("planning_id")
|
||||||
weekDay String @map("week_day")
|
weekDay String @map("week_day")
|
||||||
meal String
|
meal String
|
||||||
recipeId Int @map("recipe_id")
|
recipeId Int @map("recipe_id")
|
||||||
/// Portions actually *prepared* for this slot — always a whole multiple
|
|
||||||
/// of `recipe.portions` (a dish is cooked in whole batches). Derived from
|
|
||||||
/// `portionsRequested` + the recipe's yield via `computeBatchedPortions`
|
|
||||||
/// (`@batch-cooking/shared`) in `addPlanningItem`, never sent raw by the
|
|
||||||
/// client. On a "leftovers" item (`sourcePlanningItemId` set) it always
|
|
||||||
/// equals `portionsRequested`.
|
|
||||||
portions Int
|
portions Int
|
||||||
/// How many portions this slot must actually feed. For a fresh item
|
|
||||||
/// (`sourcePlanningItemId` null), `<= portions` — the gap is this item's
|
|
||||||
/// leftover pool. For a "leftovers" item, always `== portions` (serving
|
|
||||||
/// leftovers prepares nothing new, so it can't generate more — no
|
|
||||||
/// leftovers-on-leftovers).
|
|
||||||
portionsRequested Int @map("portions_requested")
|
|
||||||
/// Non-null only on a "leftovers" item: the item whose leftover pool it
|
|
||||||
/// draws from. `onDelete: Cascade` — deleting the source item also
|
|
||||||
/// deletes everything served from its leftovers (same reasoning as the
|
|
||||||
/// existing `Planning` -> `PlanningItem` cascade).
|
|
||||||
sourcePlanningItemId Int? @map("source_planning_item_id")
|
|
||||||
/// Portions of *this* item's leftover pool explicitly thrown away (the
|
|
||||||
/// basket's "jeter" button). Always 0 on a "leftovers" item (its own
|
|
||||||
/// pool is null by construction).
|
|
||||||
discardedPortions Int @default(0) @map("discarded_portions")
|
|
||||||
/// See `Planning.createdAt` — same admin-metrics-only timestamp.
|
/// See `Planning.createdAt` — same admin-metrics-only timestamp.
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
planning Planning @relation(fields: [planningId], references: [id], onDelete: Cascade)
|
planning Planning @relation(fields: [planningId], references: [id], onDelete: Cascade)
|
||||||
recipe Recipe @relation(fields: [recipeId], references: [id])
|
recipe Recipe @relation(fields: [recipeId], references: [id])
|
||||||
sourcePlanningItem PlanningItem? @relation("PlanningItemLeftoverSource", fields: [sourcePlanningItemId], references: [id], onDelete: Cascade)
|
|
||||||
/// The "leftovers" items served from this item's own pool — inverse of `sourcePlanningItem`.
|
|
||||||
leftoverUses PlanningItem[] @relation("PlanningItemLeftoverSource")
|
|
||||||
|
|
||||||
@@map("planning_item")
|
@@map("planning_item")
|
||||||
}
|
}
|
||||||
|
|
@ -377,6 +346,7 @@ model Recipe {
|
||||||
steps Step[]
|
steps Step[]
|
||||||
planningItems PlanningItem[]
|
planningItems PlanningItem[]
|
||||||
favoritedBy RecipeFavorite[]
|
favoritedBy RecipeFavorite[]
|
||||||
|
diets RecipeDiet[]
|
||||||
|
|
||||||
@@unique([sourceId, externalId])
|
@@unique([sourceId, externalId])
|
||||||
@@map("recipe")
|
@@map("recipe")
|
||||||
|
|
@ -397,6 +367,21 @@ model RecipeFavorite {
|
||||||
@@map("recipe_favorite")
|
@@map("recipe_favorite")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Explicit join table for the recipe <-> diet "associated regime" tags
|
||||||
|
/// (e.g. a recipe can be tagged both `Végétarien` and `Sans gluten`) — a
|
||||||
|
/// manual reminder set by whoever creates/edits the recipe, not computed
|
||||||
|
/// from its ingredients.
|
||||||
|
model RecipeDiet {
|
||||||
|
recipeId Int @map("recipe_id")
|
||||||
|
dietId Int @map("diet_id")
|
||||||
|
|
||||||
|
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
||||||
|
diet Diet @relation(fields: [dietId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@id([recipeId, dietId])
|
||||||
|
@@map("recipe_diet")
|
||||||
|
}
|
||||||
|
|
||||||
/// `key` is `@unique` — not in the original spec doc, added so the seed
|
/// `key` is `@unique` — not in the original spec doc, added so the seed
|
||||||
/// script (reference-seed-data.ts) can `upsert` by key and stay
|
/// script (reference-seed-data.ts) can `upsert` by key and stay
|
||||||
/// idempotent/safe to re-run, same reason as `Diet.key`/`Category.key`. A
|
/// idempotent/safe to re-run, same reason as `Diet.key`/`Category.key`. A
|
||||||
|
|
@ -684,19 +669,14 @@ model Unit {
|
||||||
/// quantity per recipe, so this join table carries quantity + unit
|
/// quantity per recipe, so this join table carries quantity + unit
|
||||||
/// (project decision, not in the original spec doc).
|
/// (project decision, not in the original spec doc).
|
||||||
model RecipeIngredient {
|
model RecipeIngredient {
|
||||||
recipeId Int @map("recipe_id")
|
recipeId Int @map("recipe_id")
|
||||||
ingredientId Int @map("ingredient_id")
|
ingredientId Int @map("ingredient_id")
|
||||||
/// `null` (with `unitId` also `null`) = a "selon le goût" seasoning line —
|
quantity Decimal @db.Decimal(10, 2)
|
||||||
/// salt, pepper… added to taste, with nothing to scale by portions or to
|
unitId Int @map("unit_id")
|
||||||
/// aggregate on a shopping list. The "both null or neither" invariant is
|
|
||||||
/// enforced service-side (and by `recipeIngredientInputSchema`), not by
|
|
||||||
/// the schema — same posture as this file's other cross-field invariants.
|
|
||||||
quantity Decimal? @db.Decimal(10, 2)
|
|
||||||
unitId Int? @map("unit_id")
|
|
||||||
|
|
||||||
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
||||||
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
|
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
|
||||||
unit Unit? @relation(fields: [unitId], references: [id])
|
unit Unit @relation(fields: [unitId], references: [id])
|
||||||
|
|
||||||
@@id([recipeId, ingredientId])
|
@@id([recipeId, ingredientId])
|
||||||
@@map("recipe_ingredient")
|
@@map("recipe_ingredient")
|
||||||
|
|
@ -721,33 +701,6 @@ model TechStep {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
key String @unique
|
key String @unique
|
||||||
|
|
||||||
/// `true` = a "placeholder" technique: a free-text technique a user typed
|
|
||||||
/// while correcting a step, because nothing in the seeded catalog matched
|
|
||||||
/// (issue #131 for the ingredient precedent this mirrors — see
|
|
||||||
/// `Ingredient.isPlaceholder`). Such a row has `displayName` non-null, a
|
|
||||||
/// generated `key` (`placeholder:<uuid>`, never an i18n label), no
|
|
||||||
/// training data of its own, and is excluded from
|
|
||||||
/// `GET /reference/tech-steps`. It is still referenced by `StepTechStep`
|
|
||||||
/// like any other technique (the correction that created it applies
|
|
||||||
/// immediately), but a maintainer reviews it at
|
|
||||||
/// `/admin/catalogue` (Techniques tab) and promotes it into the real
|
|
||||||
/// catalog by hand (`reference-seed-data.ts` + `tech-step-training-data.ts`).
|
|
||||||
isPlaceholder Boolean @default(false) @map("is_placeholder")
|
|
||||||
/// The exact text the user typed — `null` for a real catalog row (whose
|
|
||||||
/// label lives in i18n under `catalog.techSteps.<key>`). Non-null iff
|
|
||||||
/// `isPlaceholder`, enforced service-side.
|
|
||||||
displayName String? @map("display_name")
|
|
||||||
/// Profile that first proposed this technique — context for the admin
|
|
||||||
/// review. `onDelete: SetNull` so deleting an account never blocks on the
|
|
||||||
/// recipes still using its placeholder. `null` for a real catalog row.
|
|
||||||
createdById Int? @map("created_by_id")
|
|
||||||
/// When this placeholder was proposed. `null` for a real catalog row.
|
|
||||||
createdAt DateTime? @map("created_at")
|
|
||||||
/// Stamped once a maintainer has triaged this proposal
|
|
||||||
/// (`PATCH /admin/api/catalog/tech-step-placeholders/mark-reviewed`).
|
|
||||||
/// `null` while pending / for a real catalog row.
|
|
||||||
reviewedAt DateTime? @map("reviewed_at")
|
|
||||||
|
|
||||||
steps StepTechStep[]
|
steps StepTechStep[]
|
||||||
/// Corrections where this technique was the *previous* (possibly wrong)
|
/// Corrections where this technique was the *previous* (possibly wrong)
|
||||||
/// match — see `StepTechStepCorrection.previousTechStepId`.
|
/// match — see `StepTechStepCorrection.previousTechStepId`.
|
||||||
|
|
@ -758,10 +711,7 @@ model TechStep {
|
||||||
/// Training-corpus suggestions targeting this technique — see
|
/// Training-corpus suggestions targeting this technique — see
|
||||||
/// `TechStepTrainingSuggestion`.
|
/// `TechStepTrainingSuggestion`.
|
||||||
trainingSuggestions TechStepTrainingSuggestion[]
|
trainingSuggestions TechStepTrainingSuggestion[]
|
||||||
/// The profile that proposed this row when it is a placeholder — see `createdById`.
|
|
||||||
createdBy UserProfile? @relation("TechStepPlaceholderCreator", fields: [createdById], references: [id], onDelete: SetNull)
|
|
||||||
|
|
||||||
@@index([isPlaceholder])
|
|
||||||
@@map("tech_step")
|
@@map("tech_step")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -860,9 +810,6 @@ model StepTechStep {
|
||||||
/// Utensils mentioned in the same clause as this technique occurrence —
|
/// Utensils mentioned in the same clause as this technique occurrence —
|
||||||
/// see `StepTechStepUtensil`.
|
/// see `StepTechStepUtensil`.
|
||||||
utensils StepTechStepUtensil[]
|
utensils StepTechStepUtensil[]
|
||||||
/// Temperatures mentioned in the same clause as this technique occurrence
|
|
||||||
/// — see `StepTechStepTemperature`.
|
|
||||||
temperatures StepTechStepTemperature[]
|
|
||||||
|
|
||||||
@@id([stepId, order])
|
@@id([stepId, order])
|
||||||
@@map("step_tech_step")
|
@@map("step_tech_step")
|
||||||
|
|
@ -921,38 +868,6 @@ model StepTechStepUtensil {
|
||||||
@@map("step_tech_step_utensil")
|
@@map("step_tech_step_utensil")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A temperature mention found in the same *clause* as one `StepTechStep`
|
|
||||||
/// occurrence — same association rule as `StepTechStepIngredient` /
|
|
||||||
/// `StepTechStepUtensil` (see their doc comments). Detected by
|
|
||||||
/// `services/tech-step-intent-service`'s regex pass (`temperature_extraction.py`),
|
|
||||||
/// not a `PhraseMatcher` — a temperature is numeric/pattern, not a word from
|
|
||||||
/// a finite vocabulary. Carries no foreign key: the structured value *is*
|
|
||||||
/// the data. At least one of `celsius` / `gasMark` / `qualitative` is
|
|
||||||
/// non-null:
|
|
||||||
/// - `celsius` — an oven/pan temperature in °C (a °F mention is
|
|
||||||
/// converted + rounded upstream).
|
|
||||||
/// - `gasMark` — a gas-mark / thermostat number ("th. 6").
|
|
||||||
/// - `qualitative` — "low" | "medium" | "high" ("feu doux/moyen/vif").
|
|
||||||
/// `raw` is the exact recognized fragment, `start`/`end` its span in
|
|
||||||
/// `Step.description` ([start, end), same as `StepTechStep.start`/`end`).
|
|
||||||
/// `source` mirrors the others (`"auto"` only today — no correction UI).
|
|
||||||
model StepTechStepTemperature {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
stepId Int @map("step_id")
|
|
||||||
techStepOrder Int @map("tech_step_order")
|
|
||||||
start Int
|
|
||||||
end Int
|
|
||||||
celsius Decimal? @db.Decimal(6, 2)
|
|
||||||
gasMark Int? @map("gas_mark")
|
|
||||||
qualitative String?
|
|
||||||
raw String
|
|
||||||
source String @default("auto")
|
|
||||||
|
|
||||||
stepTechStep StepTechStep @relation(fields: [stepId, techStepOrder], references: [stepId, order], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@map("step_tech_step_temperature")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One user-submitted correction to a `Step`'s detected techniques —
|
/// One user-submitted correction to a `Step`'s detected techniques —
|
||||||
/// captures ADD (a missing technique the classifier didn't find),
|
/// captures ADD (a missing technique the classifier didn't find),
|
||||||
/// REMOVE (a wrong technique it did), or RELABEL (both) as a single shape:
|
/// REMOVE (a wrong technique it did), or RELABEL (both) as a single shape:
|
||||||
|
|
@ -1017,7 +932,7 @@ model StepTechStepCorrection {
|
||||||
///
|
///
|
||||||
/// `suggestedSynonyms`/`suggestedUtterances` are native Postgres arrays
|
/// `suggestedSynonyms`/`suggestedUtterances` are native Postgres arrays
|
||||||
/// (`String[]`), not a join table — unlike this schema's other list-shaped
|
/// (`String[]`), not a join table — unlike this schema's other list-shaped
|
||||||
/// data (`RecipeIngredient`, `UserProfileAllergy`...), these strings are free text
|
/// data (`RecipeDiet`, `UserProfileAllergy`...), these strings are free text
|
||||||
/// proposed once for a human to read, not ids referencing another catalog
|
/// proposed once for a human to read, not ids referencing another catalog
|
||||||
/// table, so there's nothing for a join table to normalize against.
|
/// table, so there's nothing for a join table to normalize against.
|
||||||
model TechStepTrainingSuggestion {
|
model TechStepTrainingSuggestion {
|
||||||
|
|
@ -1114,145 +1029,3 @@ model WorkerHeartbeat {
|
||||||
|
|
||||||
@@map("worker_heartbeats")
|
@@map("worker_heartbeats")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Idempotency ledger for one-time data backfills run from the production
|
|
||||||
/// image's start command (`src/scripts/backfill-runtime.ts`, invoked in
|
|
||||||
/// apps/api/Dockerfile's CMD after `prisma migrate deploy` + `seed-runtime`).
|
|
||||||
///
|
|
||||||
/// The CMD re-runs on every container start, but a backfill here *rewrites*
|
|
||||||
/// existing rows (unlike `seed-runtime`, which only upserts and is safe to
|
|
||||||
/// repeat) — e.g. `step_tech_step_metadata_20260904` recomputes every
|
|
||||||
/// `StepTechStep` sequence against the intent service. So each backfill
|
|
||||||
/// gates itself on a row in this table: `key` present → already done, skip;
|
|
||||||
/// absent → run it, then insert the row. A schema migration only ever
|
|
||||||
/// creates the table structure; it can't run TypeScript against the intent
|
|
||||||
/// service, which is why this is a start-command step and not a migration.
|
|
||||||
model BackfillState {
|
|
||||||
key String @id
|
|
||||||
completedAt DateTime @default(now()) @map("completed_at")
|
|
||||||
|
|
||||||
@@map("backfill_state")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Catalogue des cibles de surlignage valides pour un didacticiel guidé —
|
|
||||||
/// même esprit que `TechStep`/`Unit`/`Diet` : une valeur de référence
|
|
||||||
/// maintenue par le code, seedée (`reference-seed-data.ts`). Chaque `key`
|
|
||||||
/// correspond à un attribut `data-tour-<key>` réellement posé quelque part
|
|
||||||
/// dans le frontend ; un admin choisit parmi ces cibles en construisant un
|
|
||||||
/// scénario, il ne peut pas en inventer une (poser un nouveau `data-tour-*`
|
|
||||||
/// reste un changement de code + une ligne seedée ici). Voir issues #42/#44.
|
|
||||||
model TourTarget {
|
|
||||||
key String @id
|
|
||||||
/// Libellé pour aider l'admin à choisir dans le formulaire, ex.
|
|
||||||
/// "Bouton + d'un créneau du planning".
|
|
||||||
description String
|
|
||||||
|
|
||||||
steps TourScenarioStep[]
|
|
||||||
|
|
||||||
@@map("tour_target")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Un scénario de didacticiel — une séquence d'étapes surlignées, rejouable
|
|
||||||
/// et éditable depuis l'admin (#44). `key` est une clé stable (comme
|
|
||||||
/// `Diet.key`/`Source.key`) : `"general"` / `"techStepMetadata"` pour les
|
|
||||||
/// deux scénarios seedés, libre pour un scénario créé par un admin.
|
|
||||||
model TourScenario {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
key String @unique
|
|
||||||
/// Libellé admin, ex. "Tour d'orientation".
|
|
||||||
name String
|
|
||||||
/// Désactiver sans supprimer — `GET /reference/tours` ne renvoie que les
|
|
||||||
/// scénarios activés.
|
|
||||||
enabled Boolean @default(true)
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
|
||||||
|
|
||||||
steps TourScenarioStep[]
|
|
||||||
progress TourProgress[]
|
|
||||||
|
|
||||||
@@map("tour_scenario")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Une étape d'un scénario : (si `route` est défini) navigue vers cette
|
|
||||||
/// route, puis surligne l'élément portant `data-tour-<targetKey>` avec un
|
|
||||||
/// popover `title`/`content`. `title`/`content` sont du **texte libre**
|
|
||||||
/// saisi par l'admin, pas des clés i18n (#44 : le chrome de l'UI vient
|
|
||||||
/// d'i18next, le contenu administré dynamiquement est du texte, comme le
|
|
||||||
/// nom d'une recette).
|
|
||||||
model TourScenarioStep {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
scenarioId Int @map("scenario_id")
|
|
||||||
order Int
|
|
||||||
/// Route React Router à ouvrir avant cette étape ; `null` = rester sur la
|
|
||||||
/// route de l'étape précédente.
|
|
||||||
route String?
|
|
||||||
targetKey String @map("target_key")
|
|
||||||
title String
|
|
||||||
content String
|
|
||||||
|
|
||||||
scenario TourScenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade)
|
|
||||||
target TourTarget @relation(fields: [targetKey], references: [key])
|
|
||||||
|
|
||||||
@@unique([scenarioId, order])
|
|
||||||
@@map("tour_scenario_step")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Présence d'une ligne = ce profil a *vu* ce scénario. Le terminer **ou**
|
|
||||||
/// le passer volontairement crée la ligne (pas de re-nag après un skip
|
|
||||||
/// délibéré). Révise la version de #42 (`tourKey: String` libre) en une
|
|
||||||
/// vraie FK vers `TourScenario` (#44).
|
|
||||||
model TourProgress {
|
|
||||||
userProfileId Int @map("user_profile_id")
|
|
||||||
scenarioId Int @map("scenario_id")
|
|
||||||
completedAt DateTime @default(now()) @map("completed_at")
|
|
||||||
|
|
||||||
userProfile UserProfile @relation(fields: [userProfileId], references: [id], onDelete: Cascade)
|
|
||||||
scenario TourScenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@id([userProfileId, scenarioId])
|
|
||||||
@@map("tour_progress")
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
// Contact
|
|
||||||
// In-app "report a bug / suggestion" form — user-submitted, read-only for
|
|
||||||
// admins at /admin/messages. See issues #80 (this) and #81 (turn a message
|
|
||||||
// into a Forgejo issue).
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// A message sent via the app's contact form (bug, suggestion, other).
|
|
||||||
/// Written once by a signed-in user, never edited afterwards — consulted
|
|
||||||
/// read-only from the admin app. `authorId` is nullable + `onDelete: SetNull`
|
|
||||||
/// (same choice as `Ingredient.createdById`): a bug report keeps its value
|
|
||||||
/// even if the account that sent it is deleted in the meantime.
|
|
||||||
model ContactMessage {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
authorId Int? @map("author_id")
|
|
||||||
/// "bug" | "suggestion" | "other" — a plain `String` backed by the shared
|
|
||||||
/// `CONTACT_MESSAGE_TYPES` const (same approach as
|
|
||||||
/// `TechStepTrainingSuggestion.status` / `AnalyticsEvent.type`): adding a
|
|
||||||
/// value is one line, no Postgres enum migration.
|
|
||||||
type String
|
|
||||||
message String
|
|
||||||
/// Technical context captured automatically at submit time, for triage —
|
|
||||||
/// all nullable (an older client, or a direct API call, can omit them).
|
|
||||||
pageUrl String? @map("page_url")
|
|
||||||
userAgent String? @map("user_agent")
|
|
||||||
appVersion String? @map("app_version")
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
|
||||||
|
|
||||||
/// Number of the Forgejo issue an admin created from this message via
|
|
||||||
/// `/admin/messages` (issue #81). `null` = no ticket yet. Non-null ⇒ the
|
|
||||||
/// "Créer un ticket" button disappears (one message links to at most one
|
|
||||||
/// issue).
|
|
||||||
forgejoIssueNumber Int? @map("forgejo_issue_number")
|
|
||||||
/// The issue's html URL (`<base>/<owner>/<repo>/issues/<n>`) — stored
|
|
||||||
/// as-is rather than rebuilt from `FORGEJO_*` config, so the link stays
|
|
||||||
/// correct even if that config changes later.
|
|
||||||
forgejoIssueUrl String? @map("forgejo_issue_url")
|
|
||||||
|
|
||||||
author UserProfile? @relation(fields: [authorId], references: [id], onDelete: SetNull)
|
|
||||||
|
|
||||||
@@index([createdAt])
|
|
||||||
@@map("contact_message")
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,9 @@ import { errorLogger } from "./middlewares/error-logger.js";
|
||||||
import { requestLogger } from "./middlewares/request-logger.js";
|
import { requestLogger } from "./middlewares/request-logger.js";
|
||||||
import { adminRouter } from "./modules/admin/admin.routes.js";
|
import { adminRouter } from "./modules/admin/admin.routes.js";
|
||||||
import { authRouter } from "./modules/auth/auth.routes.js";
|
import { authRouter } from "./modules/auth/auth.routes.js";
|
||||||
import { contactRouter } from "./modules/contact/contact.routes.js";
|
|
||||||
import { cookingSessionRouter } from "./modules/cooking-session/cooking-session.routes.js";
|
import { cookingSessionRouter } from "./modules/cooking-session/cooking-session.routes.js";
|
||||||
import { houseRouter } from "./modules/house/house.routes.js";
|
import { houseRouter } from "./modules/house/house.routes.js";
|
||||||
import { techStepWorkerRouter } from "./modules/internal/tech-step-worker.routes.js";
|
import { techStepWorkerRouter } from "./modules/internal/tech-step-worker.routes.js";
|
||||||
import { mobileRouter } from "./modules/mobile/mobile.routes.js";
|
|
||||||
import { planningRouter } from "./modules/planning/planning.routes.js";
|
import { planningRouter } from "./modules/planning/planning.routes.js";
|
||||||
import { preferencesRouter } from "./modules/preferences/preferences.routes.js";
|
import { preferencesRouter } from "./modules/preferences/preferences.routes.js";
|
||||||
import { profileRouter } from "./modules/profile/profile.routes.js";
|
import { profileRouter } from "./modules/profile/profile.routes.js";
|
||||||
|
|
@ -36,45 +34,26 @@ export function createServer(): ExpressServer {
|
||||||
// pipeline (its "finish" listener still fires for a request that never
|
// pipeline (its "finish" listener still fires for a request that never
|
||||||
// makes it past CORS/body-parsing, not just ones that reach a route).
|
// makes it past CORS/body-parsing, not just ones that reach a route).
|
||||||
server.addMiddleware(requestLogger);
|
server.addMiddleware(requestLogger);
|
||||||
// Allowed origin(s) come from `CORS_ORIGIN` (see its own doc comment in
|
// Two allowed origins: the main app (`CORS_ORIGIN`) and the separate
|
||||||
// config/env.ts for why the packaged Android app's origin must always be
|
// admin app (`ADMIN_CORS_ORIGIN`). The `cors` package matches an incoming
|
||||||
// one of them). The admin surface (`/admin/*`) is served by this same API
|
// `Origin` against any entry of the list.
|
||||||
// and consumed by `apps/web`'s own `/admin/*` routes — same origin as the
|
server.setupCore({ corsOrigin: [env.CORS_ORIGIN, env.ADMIN_CORS_ORIGIN] });
|
||||||
// rest of the app, so no extra CORS entry.
|
|
||||||
server.setupCore({ corsOrigin: env.CORS_ORIGIN });
|
|
||||||
|
|
||||||
server.addRoute("get", "/health", (_req: Request, res: Response) => {
|
server.addRoute("get", "/health", (_req: Request, res: Response) => {
|
||||||
res.status(200).json({ status: "ok" });
|
res.status(200).json({ status: "ok" });
|
||||||
});
|
});
|
||||||
|
|
||||||
server.mountRouter("/auth", authRouter);
|
server.mountRouter("/auth", authRouter);
|
||||||
// Admin application surface — its own auth (`requireAdmin`, distinct
|
// Admin application surface (`apps/admin-web`) — its own auth
|
||||||
// cookie/secret), never the end-user session. Mounted at `/admin/api`,
|
// (`requireAdmin`, distinct cookie/secret), never the end-user session.
|
||||||
// *not* bare `/admin`: apps/web's own admin pages live at `/admin/*` too
|
server.mountRouter("/admin", adminRouter);
|
||||||
// (`/admin/monitoring`, `/admin/login`...), and this API is served from
|
|
||||||
// the same origin/process as that frontend (see `serveStaticFrontend`
|
|
||||||
// below). A bare `/admin` mount put `GET /admin/monitoring` (API) and the
|
|
||||||
// page route `/admin/monitoring` in direct conflict — Express matches by
|
|
||||||
// path alone, so a hard navigation/refresh on that page was answered by
|
|
||||||
// this router's own handler (raw JSON) instead of falling through to the
|
|
||||||
// SPA's `index.html`. The `/api` segment guarantees no current or future
|
|
||||||
// admin page path can ever collide with an admin API path again.
|
|
||||||
server.mountRouter("/admin/api", adminRouter);
|
|
||||||
server.mountRouter("/house", houseRouter);
|
server.mountRouter("/house", houseRouter);
|
||||||
// In-app "signaler un bug / suggestion" form (issue #80) — session
|
|
||||||
// required, one POST. Admins read these rows via `/admin/api/contact`,
|
|
||||||
// never this router.
|
|
||||||
server.mountRouter("/contact", contactRouter);
|
|
||||||
// Not user-facing — `services/tech-step-llm-worker` only, guarded by
|
// Not user-facing — `services/tech-step-llm-worker` only, guarded by
|
||||||
// `requireInternalWorker` on every route within (see that router's own
|
// `requireInternalWorker` on every route within (see that router's own
|
||||||
// doc comment), never `requireAuth`. Mounted alongside the other routers
|
// doc comment), never `requireAuth`. Mounted alongside the other routers
|
||||||
// rather than nested under one of them since it isn't scoped to a single
|
// rather than nested under one of them since it isn't scoped to a single
|
||||||
// recipe/step the way `recipeRouter`'s own correction routes are.
|
// recipe/step the way `recipeRouter`'s own correction routes are.
|
||||||
server.mountRouter("/internal/tech-steps", techStepWorkerRouter);
|
server.mountRouter("/internal/tech-steps", techStepWorkerRouter);
|
||||||
// Public proxy for Forgejo's Releases API (issues #65/#66) — see
|
|
||||||
// mobile.service.ts's doc comment for why this can't just be called
|
|
||||||
// directly from the browser.
|
|
||||||
server.mountRouter("/mobile", mobileRouter);
|
|
||||||
server.mountRouter("/planning", planningRouter);
|
server.mountRouter("/planning", planningRouter);
|
||||||
server.mountRouter("/preferences", preferencesRouter);
|
server.mountRouter("/preferences", preferencesRouter);
|
||||||
server.mountRouter("/profile", profileRouter);
|
server.mountRouter("/profile", profileRouter);
|
||||||
|
|
|
||||||
|
|
@ -13,26 +13,12 @@ import { z } from "zod";
|
||||||
// separate test database this now requires.
|
// separate test database this now requires.
|
||||||
dotenv.config({ path: process.env.NODE_ENV === "test" ? ".env.test" : ".env" });
|
dotenv.config({ path: process.env.NODE_ENV === "test" ? ".env.test" : ".env" });
|
||||||
|
|
||||||
/**
|
|
||||||
* Treats a blank env var (`FOO=`) as unset. `docker-compose.yml` passes
|
|
||||||
* every optional var through as `${FOO:-}`, which resolves to `""` (not an
|
|
||||||
* absent key) when the stack's `.env` leaves it empty — and a `.env` file
|
|
||||||
* is `.dockerignore`d, so that passthrough is the container's only source
|
|
||||||
* of these. Fields that must *fail closed* on a blank value rather than run
|
|
||||||
* their own format check against `""` (and crash `parse()` at boot) wrap
|
|
||||||
* their schema in this. Used for the `FORGEJO_*` trio below; `COOKIE_SECURE`
|
|
||||||
* folds the same `"" -> undefined` step into its own `.transform`.
|
|
||||||
*/
|
|
||||||
const blankAsUnset = (value: unknown): unknown => (value === "" ? undefined : value);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schema for every environment variable the API reads. Parsing (below)
|
* Schema for every environment variable the API reads. Parsing (below)
|
||||||
* fails fast at startup if something required is missing/invalid, instead
|
* fails fast at startup if something required is missing/invalid, instead
|
||||||
* of surfacing as a confusing runtime error later. Exported so the parsing
|
* of surfacing as a confusing runtime error later.
|
||||||
* rules (e.g. `blankAsUnset` on the `FORGEJO_*` trio) can be unit-tested
|
|
||||||
* without the process-wide `process.env` the singleton below parses.
|
|
||||||
*/
|
*/
|
||||||
export const envSchema = z.object({
|
const envSchema = z.object({
|
||||||
/** Runtime mode — also toggles test-only behavior (e.g. cheaper argon2 cost, see auth.service.ts). */
|
/** Runtime mode — also toggles test-only behavior (e.g. cheaper argon2 cost, see auth.service.ts). */
|
||||||
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
||||||
/** Port the HTTP server listens on. */
|
/** Port the HTTP server listens on. */
|
||||||
|
|
@ -48,42 +34,8 @@ export const envSchema = z.object({
|
||||||
JWT_EXPIRES_IN: z.string().default("7d"),
|
JWT_EXPIRES_IN: z.string().default("7d"),
|
||||||
/** Name of the httpOnly cookie carrying the session JWT. */
|
/** Name of the httpOnly cookie carrying the session JWT. */
|
||||||
AUTH_COOKIE_NAME: z.string().default("session"),
|
AUTH_COOKIE_NAME: z.string().default("session"),
|
||||||
/**
|
/** Origin allowed by CORS — must match wherever apps/web is served from. */
|
||||||
* Origin(s) allowed by CORS, comma-separated — parsed below into a
|
CORS_ORIGIN: z.string().default("http://localhost:5173"),
|
||||||
* `string[]` for `ExpressServerCoreOptions.corsOrigin`
|
|
||||||
* (`app.ts`/`express-server.ts`, which passes it straight to the `cors`
|
|
||||||
* package) and reused as-is for the Socket.IO handshake's own CORS config
|
|
||||||
* (`server.ts`, which needs its own since the WS upgrade never runs
|
|
||||||
* through `setupCore`'s middleware).
|
|
||||||
*
|
|
||||||
* Must always include `https://localhost`: the packaged Android app's
|
|
||||||
* WebView is served from exactly that fixed origin (Capacitor's default
|
|
||||||
* for `androidScheme`/hostname — see `apps/web/capacitor.config.ts`'s own
|
|
||||||
* doc comment), and every request it makes to this API — `/auth/login`
|
|
||||||
* and `/auth/signup` included — is genuinely cross-origin, unlike
|
|
||||||
* apps/web's *browser* build, which is served from the very same origin
|
|
||||||
* as this API in production (see docker-compose.yml's `app` service) and
|
|
||||||
* so never needs an entry here at all. Missing this origin doesn't fail
|
|
||||||
* loudly: the `cors` package still answers a preflight `OPTIONS` with 204
|
|
||||||
* for an origin it's about to reject (it only *omits*
|
|
||||||
* `Access-Control-Allow-Origin`), so the request the app's WebView logs
|
|
||||||
* looks identical to a normal one right up until the browser silently
|
|
||||||
* drops the real `POST` — surfacing client-side as a generic "unexpected
|
|
||||||
* error" with no 401/403 anywhere to point at the actual cause. Default
|
|
||||||
* covers both the Vite dev server and the packaged app out of the box; a
|
|
||||||
* real deployment's own web origin (e.g. a production domain) never needs
|
|
||||||
* adding here for the reason above, but the env var still allows
|
|
||||||
* extending/overriding this list (comma-separated) if that ever changes.
|
|
||||||
*/
|
|
||||||
CORS_ORIGIN: z
|
|
||||||
.string()
|
|
||||||
.default("http://localhost:5173,https://localhost")
|
|
||||||
.transform((value) =>
|
|
||||||
value
|
|
||||||
.split(",")
|
|
||||||
.map((origin) => origin.trim())
|
|
||||||
.filter((origin) => origin.length > 0),
|
|
||||||
),
|
|
||||||
/**
|
/**
|
||||||
* Absolute path to the built frontend (`apps/web/dist`), to serve
|
* Absolute path to the built frontend (`apps/web/dist`), to serve
|
||||||
* alongside the API. Optional, no default — only set inside the
|
* alongside the API. Optional, no default — only set inside the
|
||||||
|
|
@ -153,30 +105,12 @@ export const envSchema = z.object({
|
||||||
.optional(),
|
.optional(),
|
||||||
/** Name of the httpOnly cookie carrying the admin session JWT — must differ from `AUTH_COOKIE_NAME` so the two sessions coexist in one browser. */
|
/** Name of the httpOnly cookie carrying the admin session JWT — must differ from `AUTH_COOKIE_NAME` so the two sessions coexist in one browser. */
|
||||||
ADMIN_COOKIE_NAME: z.string().default("admin_session"),
|
ADMIN_COOKIE_NAME: z.string().default("admin_session"),
|
||||||
|
/** Origin `apps/admin-web` is served from — added to the CORS allow-list alongside `CORS_ORIGIN`. */
|
||||||
|
ADMIN_CORS_ORIGIN: z.string().default("http://localhost:5174"),
|
||||||
/** Optional seed values read by `src/scripts/create-admin.ts` when its `--email`/`--password`/`--name` flags are omitted — never used by the running server. */
|
/** Optional seed values read by `src/scripts/create-admin.ts` when its `--email`/`--password`/`--name` flags are omitted — never used by the running server. */
|
||||||
ADMIN_INITIAL_EMAIL: z.string().optional(),
|
ADMIN_INITIAL_EMAIL: z.string().optional(),
|
||||||
ADMIN_INITIAL_PASSWORD: z.string().optional(),
|
ADMIN_INITIAL_PASSWORD: z.string().optional(),
|
||||||
ADMIN_INITIAL_NAME: z.string().optional(),
|
ADMIN_INITIAL_NAME: z.string().optional(),
|
||||||
/**
|
|
||||||
* Forgejo instance + credentials for "create an issue from a contact
|
|
||||||
* message" (`/admin/messages`, issue #81). All three `.optional()` and
|
|
||||||
* checked together by `ForgejoService.isConfigured()` — the feature
|
|
||||||
* **fails closed** when any is missing: the endpoint answers `503` and
|
|
||||||
* the admin UI hides the button (same posture as `ADMIN_JWT_SECRET` /
|
|
||||||
* `INTERNAL_WORKER_SECRET`). Only this one admin action reads them; the
|
|
||||||
* rest of the app never talks to Forgejo.
|
|
||||||
*/
|
|
||||||
FORGEJO_BASE_URL: z.preprocess(blankAsUnset, z.string().url().optional()),
|
|
||||||
/** Forgejo personal access token with the `write:issue` (or `issue`) scope. Secret — `.env.example` shows only a `changeme`. */
|
|
||||||
FORGEJO_TOKEN: z.preprocess(blankAsUnset, z.string().optional()),
|
|
||||||
/** Target repo as `owner/repo` (e.g. `kyuno/batchCooking`) — issues are always created here, never a repo chosen at call time. */
|
|
||||||
FORGEJO_REPO: z.preprocess(
|
|
||||||
blankAsUnset,
|
|
||||||
z
|
|
||||||
.string()
|
|
||||||
.regex(/^[^/\s]+\/[^/\s]+$/, "FORGEJO_REPO must look like 'owner/repo'")
|
|
||||||
.optional(),
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Parsed, validated environment — import this instead of reading `process.env` directly anywhere else. */
|
/** Parsed, validated environment — import this instead of reading `process.env` directly anywhere else. */
|
||||||
|
|
|
||||||
|
|
@ -191,114 +191,6 @@ export const UTENSILS: string[] = [
|
||||||
"mandoline",
|
"mandoline",
|
||||||
];
|
];
|
||||||
|
|
||||||
// Guided-tour highlight targets (issues #42/#44). Each `key` is the suffix
|
|
||||||
// of a `data-tour-<key>` attribute the frontend puts on the element the
|
|
||||||
// tour points at; `description` is the hint an admin sees when picking a
|
|
||||||
// target in the scenario editor. Reference data maintained here, like
|
|
||||||
// `TECH_STEPS`/`UTENSILS` — adding a real new target needs a frontend
|
|
||||||
// `data-tour-*` *and* a line here.
|
|
||||||
export const TOUR_TARGETS: Array<{ key: string; description: string }> = [
|
|
||||||
{ key: "nav-planning", description: "Lien « Planning » de la barre latérale" },
|
|
||||||
{ key: "nav-recipes", description: "Lien « Recettes » de la barre latérale" },
|
|
||||||
{ key: "nav-shopping-list", description: "Lien « Liste de courses » de la barre latérale" },
|
|
||||||
{ key: "planning-grid", description: "La grille du planning de la semaine" },
|
|
||||||
{ key: "planning-add-recipe", description: "Bouton « + » d'un créneau du planning" },
|
|
||||||
{ key: "planning-cook-button", description: "Bouton « Cuisiner » de l'en-tête du planning" },
|
|
||||||
{ key: "shopping-list-summary", description: "La liste de courses générée pour la semaine" },
|
|
||||||
{
|
|
||||||
key: "techstep-description",
|
|
||||||
description: "Le texte d'une étape de recette, avec les techniques surlignées",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "techstep-technique-picker",
|
|
||||||
description: "Le sélecteur de technique du popover de correction",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// The two tours filed in issue #42, as the DB-backed scenarios #44 defines.
|
|
||||||
// Only *created* if absent (an admin owns name/enabled/steps afterwards —
|
|
||||||
// see `seedTourScenarios`); `content`/`title` are plain French text, not
|
|
||||||
// i18n keys (#44). The second tour is deliberately short for now — issue
|
|
||||||
// #36 (correction popover closes mid-selection) is still open, so v1 only
|
|
||||||
// covers the gestures that already work (select text, pick a technique).
|
|
||||||
export const TOUR_SCENARIOS: Array<{
|
|
||||||
key: string;
|
|
||||||
name: string;
|
|
||||||
steps: Array<{ route: string | null; targetKey: string; title: string; content: string }>;
|
|
||||||
}> = [
|
|
||||||
{
|
|
||||||
key: "general",
|
|
||||||
name: "Tour d'orientation",
|
|
||||||
steps: [
|
|
||||||
{
|
|
||||||
route: "/",
|
|
||||||
targetKey: "nav-planning",
|
|
||||||
title: "Votre semaine, ici",
|
|
||||||
content: "Le planning est votre point de départ : ce que vous mangez, jour par jour.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
route: null,
|
|
||||||
targetKey: "nav-recipes",
|
|
||||||
title: "Vos recettes",
|
|
||||||
content:
|
|
||||||
"Retrouvez vos recettes, importez-en depuis d'autres sites, et filtrez selon vos préférences.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
route: null,
|
|
||||||
targetKey: "nav-shopping-list",
|
|
||||||
title: "La liste de courses",
|
|
||||||
content:
|
|
||||||
"Générée automatiquement à partir de tout ce que vous avez planifié pour la semaine.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
route: null,
|
|
||||||
targetKey: "planning-grid",
|
|
||||||
title: "Sept jours, cinq repas",
|
|
||||||
content: "Chaque case est un créneau. Ajoutez-y des recettes, ajustez les portions.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
route: null,
|
|
||||||
targetKey: "planning-add-recipe",
|
|
||||||
title: "Ajouter une recette",
|
|
||||||
content: "Ce bouton ouvre le sélecteur de recettes pour remplir un créneau.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
route: null,
|
|
||||||
targetKey: "planning-cook-button",
|
|
||||||
title: "Passer en cuisine",
|
|
||||||
content:
|
|
||||||
"Une fois la semaine planifiée, ce bouton réorganise tout en une séance de cuisine optimisée.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
route: "/liste-de-courses",
|
|
||||||
targetKey: "shopping-list-summary",
|
|
||||||
title: "Tout ce qu'il vous faut",
|
|
||||||
content: "Les ingrédients de toutes vos recettes de la semaine, regroupés et additionnés.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "techStepMetadata",
|
|
||||||
name: "Corriger les techniques d'une recette",
|
|
||||||
steps: [
|
|
||||||
{
|
|
||||||
route: null,
|
|
||||||
targetKey: "techstep-description",
|
|
||||||
title: "Les techniques repérées",
|
|
||||||
content:
|
|
||||||
"Les mots surlignés sont des techniques de cuisson détectées automatiquement. Sélectionnez un passage pour le corriger.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
route: null,
|
|
||||||
targetKey: "techstep-technique-picker",
|
|
||||||
title: "Choisir la bonne technique",
|
|
||||||
content:
|
|
||||||
"Indiquez la technique correcte pour le passage sélectionné — la correction est immédiate.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// The 14 allergens EU Regulation 1169/2011 (Annex II) requires food
|
// The 14 allergens EU Regulation 1169/2011 (Annex II) requires food
|
||||||
// businesses to declare — a standard, defensible reference list rather than
|
// businesses to declare — a standard, defensible reference list rather than
|
||||||
// an invented one. Split into ALLERGY (classic IgE-mediated immune
|
// an invented one. Split into ALLERGY (classic IgE-mediated immune
|
||||||
|
|
@ -1524,47 +1416,4 @@ export async function seedReferenceData(prisma: PrismaClient): Promise<void> {
|
||||||
if (dietLinks.length > 0) {
|
if (dietLinks.length > 0) {
|
||||||
await prisma.ingredientDiet.createMany({ data: dietLinks, skipDuplicates: true });
|
await prisma.ingredientDiet.createMany({ data: dietLinks, skipDuplicates: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
await seedTourScenarios(prisma);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Seeds the guided-tour reference data (issues #42/#44):
|
|
||||||
*
|
|
||||||
* - `TourTarget` rows — upserted by key with `update: { description }`, same
|
|
||||||
* as `Unit` above: a reseed must correct an edited hint, not just skip.
|
|
||||||
* - `TourScenario` + its steps — **create-if-absent only**. Once a scenario
|
|
||||||
* exists, an admin owns its `name`/`enabled`/steps (that's the whole
|
|
||||||
* point of #44's CRUD), so a later deploy's reseed must never clobber
|
|
||||||
* those. In the test suite every `resetDatabase()` truncates first, so
|
|
||||||
* the defaults are always recreated fresh there.
|
|
||||||
*/
|
|
||||||
async function seedTourScenarios(prisma: PrismaClient): Promise<void> {
|
|
||||||
for (const { key, description } of TOUR_TARGETS) {
|
|
||||||
await prisma.tourTarget.upsert({
|
|
||||||
where: { key },
|
|
||||||
update: { description },
|
|
||||||
create: { key, description },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const { key, name, steps } of TOUR_SCENARIOS) {
|
|
||||||
const existing = await prisma.tourScenario.findUnique({ where: { key } });
|
|
||||||
if (existing) continue;
|
|
||||||
await prisma.tourScenario.create({
|
|
||||||
data: {
|
|
||||||
key,
|
|
||||||
name,
|
|
||||||
steps: {
|
|
||||||
create: steps.map((step, index) => ({
|
|
||||||
order: index,
|
|
||||||
route: step.route,
|
|
||||||
targetKey: step.targetKey,
|
|
||||||
title: step.title,
|
|
||||||
content: step.content,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
import { HttpError } from "@batch-cooking/error-tools";
|
|
||||||
import { ErrorCode } from "@batch-cooking/shared";
|
|
||||||
import { env } from "../../config/env.js";
|
|
||||||
import { logger } from "../logger.service.js";
|
|
||||||
|
|
||||||
/** The slice of Forgejo's `POST .../issues` response this integration reads. */
|
|
||||||
interface ForgejoIssueResponse {
|
|
||||||
number: number;
|
|
||||||
html_url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The one place `apps/api` talks to Forgejo — creating an issue from a
|
|
||||||
* contact message (`/admin/messages`, issue #81). A class + singleton
|
|
||||||
* (`forgejo` below), same convention as `logger` / `analytics`.
|
|
||||||
*
|
|
||||||
* **Fail-closed**: every method is a no-op / typed error unless all three
|
|
||||||
* `FORGEJO_*` env vars are set (see `config/env.ts`). Callers check
|
|
||||||
* {@link isConfigured} first and answer `503` themselves; {@link createIssue}
|
|
||||||
* re-checks as a backstop.
|
|
||||||
*/
|
|
||||||
export class ForgejoService {
|
|
||||||
/** True once `FORGEJO_BASE_URL`, `FORGEJO_TOKEN` and `FORGEJO_REPO` are all set. */
|
|
||||||
public isConfigured(): boolean {
|
|
||||||
return (
|
|
||||||
env.FORGEJO_BASE_URL !== undefined &&
|
|
||||||
env.FORGEJO_TOKEN !== undefined &&
|
|
||||||
env.FORGEJO_REPO !== undefined
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates an issue in `FORGEJO_REPO` with the given title/body (no labels
|
|
||||||
* — a deliberate #81 decision, the admin adds them in Forgejo). Returns
|
|
||||||
* the new issue's number and html URL.
|
|
||||||
*
|
|
||||||
* @throws {HttpError} `503 FORGEJO_NOT_CONFIGURED` if the env isn't set,
|
|
||||||
* `502 FORGEJO_REQUEST_FAILED` on a network error or any non-2xx / non-JSON
|
|
||||||
* response — the caller leaves the database untouched in that case.
|
|
||||||
*/
|
|
||||||
public async createIssue(input: {
|
|
||||||
title: string;
|
|
||||||
body: string;
|
|
||||||
}): Promise<{ number: number; htmlUrl: string }> {
|
|
||||||
if (!this.isConfigured()) {
|
|
||||||
throw new HttpError(
|
|
||||||
503,
|
|
||||||
ErrorCode.FORGEJO_NOT_CONFIGURED,
|
|
||||||
"Forgejo integration is not configured",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const url = `${env.FORGEJO_BASE_URL}/api/v1/repos/${env.FORGEJO_REPO}/issues`;
|
|
||||||
|
|
||||||
let response: Response;
|
|
||||||
try {
|
|
||||||
response = await fetch(url, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
// Forgejo/Gitea's PAT scheme — `token <pat>`, not `Bearer`.
|
|
||||||
Authorization: `token ${env.FORGEJO_TOKEN}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ title: input.title, body: input.body }),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn("Forgejo issue creation failed (network)", {
|
|
||||||
error: err instanceof Error ? err.message : String(err),
|
|
||||||
});
|
|
||||||
throw new HttpError(502, ErrorCode.FORGEJO_REQUEST_FAILED, "Forgejo request failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const detail = await response.text().catch(() => "");
|
|
||||||
logger.warn("Forgejo issue creation failed", {
|
|
||||||
status: response.status,
|
|
||||||
detail: detail.slice(0, 500),
|
|
||||||
});
|
|
||||||
throw new HttpError(
|
|
||||||
502,
|
|
||||||
ErrorCode.FORGEJO_REQUEST_FAILED,
|
|
||||||
`Forgejo issues API responded ${response.status}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = (await response.json()) as ForgejoIssueResponse;
|
|
||||||
return { number: data.number, htmlUrl: data.html_url };
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn("Forgejo issue creation failed (bad response)", {
|
|
||||||
error: err instanceof Error ? err.message : String(err),
|
|
||||||
});
|
|
||||||
throw new HttpError(
|
|
||||||
502,
|
|
||||||
ErrorCode.FORGEJO_REQUEST_FAILED,
|
|
||||||
"Forgejo response was not valid JSON",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Single shared instance — stateless, same reasoning as `logger` / `analytics`. */
|
|
||||||
export const forgejo = new ForgejoService();
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
import type { MonitoringView } from "@batch-cooking/shared";
|
|
||||||
import type { Server as SocketIoServer } from "socket.io";
|
|
||||||
import { requireAdminSocket } from "../middlewares/require-admin-socket.js";
|
|
||||||
import { getMonitoring } from "../modules/admin/admin-monitoring.service.js";
|
|
||||||
import { logger } from "./logger.service.js";
|
|
||||||
|
|
||||||
/** How often the server re-probes every dependency, in ms — see `MonitoringBroadcastService`. */
|
|
||||||
const PROBE_INTERVAL_MS = 60_000;
|
|
||||||
|
|
||||||
/** Every authenticated admin socket joins this one room — a single probe loop serves every open tab, instead of each polling independently. */
|
|
||||||
const MONITORING_ROOM = "monitoring";
|
|
||||||
|
|
||||||
/** Event name every `monitoring:update` push carries — the sole message this service ever emits. */
|
|
||||||
const UPDATE_EVENT = "monitoring:update";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pushes the admin monitoring status board to every connected admin over
|
|
||||||
* WebSocket, replacing the old `GET /admin/api/monitoring` polling
|
|
||||||
* endpoint entirely (removed — see `admin.routes.ts`'s doc comment): one
|
|
||||||
* server-side probe loop, shared by every viewer, instead of each browser
|
|
||||||
* tab re-probing Postgres/the API/the intent-service/the LLM worker on its
|
|
||||||
* own timer.
|
|
||||||
*
|
|
||||||
* Owns the *whole* `socket.io` server passed to it (`io.use`, top-level
|
|
||||||
* `"connection"` handler) — fine while this is the server's only
|
|
||||||
* real-time feature; a second one added later should likely get its own
|
|
||||||
* namespace rather than share this room's connection handler.
|
|
||||||
*/
|
|
||||||
export class MonitoringBroadcastService {
|
|
||||||
private readonly _io: SocketIoServer;
|
|
||||||
/**
|
|
||||||
* Most recent probe result — replayed to a newly-joined admin
|
|
||||||
* immediately (see the `"connection"` handler below), so a freshly
|
|
||||||
* opened tab isn't left blank for up to a minute waiting for the next
|
|
||||||
* scheduled tick. `null` until the very first probe (kicked off by
|
|
||||||
* {@link start}) completes.
|
|
||||||
*/
|
|
||||||
private _lastSnapshot: MonitoringView | null = null;
|
|
||||||
/** Handle for the running probe loop, or `null` before {@link start}/after {@link stop}. */
|
|
||||||
private _intervalHandle: NodeJS.Timeout | null = null;
|
|
||||||
|
|
||||||
public constructor(io: SocketIoServer) {
|
|
||||||
this._io = io;
|
|
||||||
this._io.use(requireAdminSocket);
|
|
||||||
this._io.on("connection", (socket) => {
|
|
||||||
void socket.join(MONITORING_ROOM);
|
|
||||||
if (this._lastSnapshot) {
|
|
||||||
socket.emit(UPDATE_EVENT, this._lastSnapshot);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts the probe loop: one immediate probe (so the very first admin
|
|
||||||
* to connect after a server restart doesn't wait a full minute), then
|
|
||||||
* every {@link PROBE_INTERVAL_MS}. Call once, at server boot
|
|
||||||
* (`server.ts`) — meant to run for the lifetime of the process, same
|
|
||||||
* posture as the tech-step worker's own cron (`server.ts` never calls
|
|
||||||
* {@link stop} today; the process exiting takes the interval with it).
|
|
||||||
*/
|
|
||||||
public start(): void {
|
|
||||||
void this._tick();
|
|
||||||
this._intervalHandle = setInterval(() => void this._tick(), PROBE_INTERVAL_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stops the probe loop. Nothing in production calls this today — it
|
|
||||||
* exists so a graceful-shutdown hook could use it later, and so tests
|
|
||||||
* can tear an instance down cleanly instead of leaking a live
|
|
||||||
* `setInterval` past their own lifetime (which would otherwise hold the
|
|
||||||
* whole Mocha process open).
|
|
||||||
*/
|
|
||||||
public stop(): void {
|
|
||||||
if (this._intervalHandle) {
|
|
||||||
clearInterval(this._intervalHandle);
|
|
||||||
this._intervalHandle = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Probes every dependency, caches the result, and broadcasts it to {@link MONITORING_ROOM}. */
|
|
||||||
private async _tick(): Promise<void> {
|
|
||||||
try {
|
|
||||||
this._lastSnapshot = await getMonitoring();
|
|
||||||
this._io.to(MONITORING_ROOM).emit(UPDATE_EVENT, this._lastSnapshot);
|
|
||||||
} catch (err) {
|
|
||||||
// getMonitoring() itself never rejects — every individual probe is
|
|
||||||
// already bounded/caught (see its own doc comment) — so this is
|
|
||||||
// purely defensive, per the repo's await-in-try/catch rule; not
|
|
||||||
// expected to actually fire.
|
|
||||||
logger.error("Monitoring probe tick failed unexpectedly", {
|
|
||||||
error: err instanceof Error ? err.message : String(err),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +1,10 @@
|
||||||
import type {
|
import type {
|
||||||
CookingBackgroundTaskView,
|
CookingBackgroundTaskView,
|
||||||
CookingPhaseKind,
|
CookingPhaseKind,
|
||||||
CookingPhaseNoteView,
|
|
||||||
CookingPhaseView,
|
CookingPhaseView,
|
||||||
CookingSessionRecipeRef,
|
CookingSessionRecipeRef,
|
||||||
CookingTaskIngredientView,
|
CookingTaskIngredientView,
|
||||||
CookingTaskView,
|
CookingTaskView,
|
||||||
CookingTemperatureView,
|
|
||||||
TechStepView,
|
TechStepView,
|
||||||
UtensilView,
|
UtensilView,
|
||||||
} from "@batch-cooking/shared";
|
} from "@batch-cooking/shared";
|
||||||
|
|
@ -25,22 +23,12 @@ import type {
|
||||||
* `aggregateShoppingList`, so the whole optimization is unit-testable
|
* `aggregateShoppingList`, so the whole optimization is unit-testable
|
||||||
* without a Postgres round-trip.
|
* without a Postgres round-trip.
|
||||||
*
|
*
|
||||||
* Two families of work are *merged* across recipes:
|
* v1 scope (see the plan / spec): preparation is the only thing *merged*
|
||||||
* - **Preparation** — a `chop`/`peel`/… technique applied to the same
|
* across recipes — a `chop`/`peel`/… technique applied to the same
|
||||||
* ingredient by two or more recipes, in a step that does nothing but
|
* ingredient by two or more recipes, in a step that does nothing but prep,
|
||||||
* prep, collapses into one {@link CookingTaskView} of `kind:
|
* collapses into a single {@link CookingTaskView} of `kind: "merged-prep"`.
|
||||||
* "merged-prep"`.
|
* Cooking steps themselves are never merged (no "same oven, same
|
||||||
* - **Setup** — the same `preheat`/`boil`/`bainMarie` action shared across
|
* temperature" reasoning yet); they're only *reordered* for parallelism.
|
||||||
* recipes collapses into one `kind: "merged-setup"` task, but *only when
|
|
||||||
* the action metadata is compatible*: two recipes that both preheat the
|
|
||||||
* oven merge only when they preheat it to the **same temperature**.
|
|
||||||
* Two preheats at different temperatures stay separate (you cannot heat
|
|
||||||
* one oven to two temperatures at once), are ordered coolest-first, and
|
|
||||||
* the mise-en-place phase carries a {@link CookingPhaseNoteView} telling
|
|
||||||
* the cook to run them in sequence.
|
|
||||||
*
|
|
||||||
* Cooking steps themselves are still never merged (no shared-oven bake
|
|
||||||
* batching yet); they're only *reordered* for parallelism.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -78,8 +66,7 @@ const PREP_TECHNIQUES: ReadonlySet<string> = new Set([
|
||||||
*
|
*
|
||||||
* - `"SETUP"` — a short active trigger, then it looks after itself: preheat
|
* - `"SETUP"` — a short active trigger, then it looks after itself: preheat
|
||||||
* the oven, bring a pot of water to the boil. Pooled into the first
|
* the oven, bring a pot of water to the boil. Pooled into the first
|
||||||
* ("mise en place") phase so it's running before it's needed, and merged
|
* ("mise en place") phase so it's running before it's needed.
|
||||||
* across recipes when compatible (see {@link extractMergedSetup}).
|
|
||||||
* - `"PASSIVE"` — unattended once started (simmer, braise, bake, marinate,
|
* - `"PASSIVE"` — unattended once started (simmer, braise, bake, marinate,
|
||||||
* rest…). Scheduled, then floated into every following phase's
|
* rest…). Scheduled, then floated into every following phase's
|
||||||
* `background` until the step that consumes it comes up.
|
* `background` until the step that consumes it comes up.
|
||||||
|
|
@ -119,13 +106,6 @@ interface OptimizerTechStepInput {
|
||||||
order: number;
|
order: number;
|
||||||
ingredients: CookingTaskIngredientView[];
|
ingredients: CookingTaskIngredientView[];
|
||||||
utensils: UtensilView[];
|
utensils: UtensilView[];
|
||||||
/**
|
|
||||||
* Target temperature(s) named in this same clause (`preheat` → "à 180 °C").
|
|
||||||
* The structured subset only — see {@link CookingTemperatureView}. Not
|
|
||||||
* scaled (a temperature doesn't grow with the portion count). `[]` when
|
|
||||||
* the clause named no temperature.
|
|
||||||
*/
|
|
||||||
temperatures: CookingTemperatureView[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One recipe step, as handed to {@link optimizeCookingPlan}. */
|
/** One recipe step, as handed to {@link optimizeCookingPlan}. */
|
||||||
|
|
@ -172,7 +152,7 @@ function scaleOf(recipe: OptimizerRecipeInput): number {
|
||||||
return recipe.portions / recipe.recipePortions;
|
return recipe.portions / recipe.recipePortions;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A step normalized for scheduling — techniques scaled, attention resolved, ingredients/utensils/temperatures unioned across its technique clauses. */
|
/** A step normalized for scheduling — techniques scaled, attention resolved, ingredients/utensils unioned across its technique clauses. */
|
||||||
interface NormalizedStep {
|
interface NormalizedStep {
|
||||||
/** Stable within one response: `step:<recipeIndex>:<stepId>` (the index disambiguates the same recipe planned twice). */
|
/** Stable within one response: `step:<recipeIndex>:<stepId>` (the index disambiguates the same recipe planned twice). */
|
||||||
taskId: string;
|
taskId: string;
|
||||||
|
|
@ -184,14 +164,10 @@ interface NormalizedStep {
|
||||||
techSteps: OptimizerTechStepInput[];
|
techSteps: OptimizerTechStepInput[];
|
||||||
attention: Attention;
|
attention: Attention;
|
||||||
isPurePrep: boolean;
|
isPurePrep: boolean;
|
||||||
/** Every technique in the step is a {@link SETUP_TECHNIQUES} one — eligible to pool via {@link extractMergedSetup}. */
|
|
||||||
isPureSetup: boolean;
|
|
||||||
dominantTechnique: TechStepView | null;
|
dominantTechnique: TechStepView | null;
|
||||||
ingredients: CookingTaskIngredientView[];
|
ingredients: CookingTaskIngredientView[];
|
||||||
utensils: UtensilView[];
|
utensils: UtensilView[];
|
||||||
/** Distinct target temperatures across the step's clauses (see {@link unionTemperatures}) — drives setup pooling and the coolest-first ordering. */
|
/** Set once merged-prep extraction absorbs this step wholesale (all its prep pooled elsewhere) — it then produces no standalone task. */
|
||||||
temperatures: CookingTemperatureView[];
|
|
||||||
/** Set once merged-prep/merged-setup extraction absorbs this step wholesale — it then produces no standalone task. */
|
|
||||||
absorbed: boolean;
|
absorbed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -239,62 +215,11 @@ function unionUtensils(utensils: UtensilView[]): UtensilView[] {
|
||||||
return [...byId.values()].sort((a, b) => a.key.localeCompare(b.key));
|
return [...byId.values()].sort((a, b) => a.key.localeCompare(b.key));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A stable canonical string for one temperature — `c<°C>` / `g<gasMark>` /
|
|
||||||
* `q<level>`, or `""` when every field is null (shouldn't happen — the
|
|
||||||
* upstream extractor guarantees one non-null — but stays total). Two
|
|
||||||
* temperatures are "the same" iff their keys are equal: **no conversion**
|
|
||||||
* between °C, gas marks and qualitative levels is guessed for a merge
|
|
||||||
* decision (same "don't invent a conversion" stance as `ShoppingListItemView`
|
|
||||||
* on units). °C is rounded to the nearest degree so `180` and `180.00`
|
|
||||||
* (a `Decimal` artefact) collapse.
|
|
||||||
*/
|
|
||||||
function temperatureKey(temperature: CookingTemperatureView): string {
|
|
||||||
if (temperature.celsius !== null) return `c${Math.round(temperature.celsius)}`;
|
|
||||||
if (temperature.gasMark !== null) return `g${temperature.gasMark}`;
|
|
||||||
if (temperature.qualitative !== null) return `q${temperature.qualitative}`;
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Unions temperatures by {@link temperatureKey}, keeping a stable order by that key. */
|
|
||||||
function unionTemperatures(temperatures: CookingTemperatureView[]): CookingTemperatureView[] {
|
|
||||||
const byKey = new Map<string, CookingTemperatureView>();
|
|
||||||
for (const temperature of temperatures) byKey.set(temperatureKey(temperature), temperature);
|
|
||||||
return [...byKey.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([, value]) => value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A single number to *order* temperatures by (coolest first) — **never** used
|
|
||||||
* to decide a merge (that's {@link temperatureKey}, exact-match only). Gas
|
|
||||||
* marks map via the French `th × 30 ≈ °C` convention and qualitative levels
|
|
||||||
* via rough midpoints, purely so a mixed list still sorts sensibly; a
|
|
||||||
* temperature-less task sorts first.
|
|
||||||
*/
|
|
||||||
function comparableTemp(temperature: CookingTemperatureView): number {
|
|
||||||
if (temperature.celsius !== null) return temperature.celsius;
|
|
||||||
if (temperature.gasMark !== null) return temperature.gasMark * 30;
|
|
||||||
if (temperature.qualitative !== null) {
|
|
||||||
return { low: 120, medium: 180, high: 240 }[temperature.qualitative];
|
|
||||||
}
|
|
||||||
return Number.NEGATIVE_INFINITY;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A setup task's sort position (coolest first) — its lowest target, or "before everything" when it has no detected temperature. */
|
|
||||||
function setupTaskTemp(task: CookingTaskView): number {
|
|
||||||
if (task.temperatures.length === 0) return Number.NEGATIVE_INFINITY;
|
|
||||||
return Math.min(...task.temperatures.map(comparableTemp));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A step is pure prep only if it has techniques and every one of them is in {@link PREP_TECHNIQUES}. */
|
/** A step is pure prep only if it has techniques and every one of them is in {@link PREP_TECHNIQUES}. */
|
||||||
function isPurePrepStep(techSteps: OptimizerTechStepInput[]): boolean {
|
function isPurePrepStep(techSteps: OptimizerTechStepInput[]): boolean {
|
||||||
return techSteps.length > 0 && techSteps.every((ts) => PREP_TECHNIQUES.has(ts.techStep.key));
|
return techSteps.length > 0 && techSteps.every((ts) => PREP_TECHNIQUES.has(ts.techStep.key));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A step is pure setup only if it has techniques and every one is in {@link SETUP_TECHNIQUES} — mirror of {@link isPurePrepStep}. */
|
|
||||||
function isPureSetupStep(techSteps: OptimizerTechStepInput[]): boolean {
|
|
||||||
return techSteps.length > 0 && techSteps.every((ts) => SETUP_TECHNIQUES.has(ts.techStep.key));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Resolves a step's {@link Attention} — SETUP wins, then a *trailing* passive technique, else ACTIVE (see {@link SETUP_TECHNIQUES}). */
|
/** Resolves a step's {@link Attention} — SETUP wins, then a *trailing* passive technique, else ACTIVE (see {@link SETUP_TECHNIQUES}). */
|
||||||
function attentionOf(techSteps: OptimizerTechStepInput[]): Attention {
|
function attentionOf(techSteps: OptimizerTechStepInput[]): Attention {
|
||||||
if (techSteps.some((ts) => SETUP_TECHNIQUES.has(ts.techStep.key))) return "SETUP";
|
if (techSteps.some((ts) => SETUP_TECHNIQUES.has(ts.techStep.key))) return "SETUP";
|
||||||
|
|
@ -326,9 +251,6 @@ function normalizeRecipe(recipe: OptimizerRecipeInput, recipeIndex: number): Nor
|
||||||
unit: line.unit,
|
unit: line.unit,
|
||||||
})),
|
})),
|
||||||
utensils: ts.utensils,
|
utensils: ts.utensils,
|
||||||
// Temperatures are not scaled — an oven set to 180 °C for 4
|
|
||||||
// portions is still 180 °C for 8.
|
|
||||||
temperatures: ts.temperatures,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const lastTech = techSteps[techSteps.length - 1];
|
const lastTech = techSteps[techSteps.length - 1];
|
||||||
|
|
@ -342,11 +264,9 @@ function normalizeRecipe(recipe: OptimizerRecipeInput, recipeIndex: number): Nor
|
||||||
techSteps,
|
techSteps,
|
||||||
attention: attentionOf(techSteps),
|
attention: attentionOf(techSteps),
|
||||||
isPurePrep: isPurePrepStep(techSteps),
|
isPurePrep: isPurePrepStep(techSteps),
|
||||||
isPureSetup: isPureSetupStep(techSteps),
|
|
||||||
dominantTechnique: lastTech ? lastTech.techStep : null,
|
dominantTechnique: lastTech ? lastTech.techStep : null,
|
||||||
ingredients: unionIngredients(techSteps.flatMap((ts) => ts.ingredients)),
|
ingredients: unionIngredients(techSteps.flatMap((ts) => ts.ingredients)),
|
||||||
utensils: unionUtensils(techSteps.flatMap((ts) => ts.utensils)),
|
utensils: unionUtensils(techSteps.flatMap((ts) => ts.utensils)),
|
||||||
temperatures: unionTemperatures(techSteps.flatMap((ts) => ts.temperatures)),
|
|
||||||
absorbed: false,
|
absorbed: false,
|
||||||
} satisfies NormalizedStep;
|
} satisfies NormalizedStep;
|
||||||
});
|
});
|
||||||
|
|
@ -363,26 +283,6 @@ function prepSignature(step: NormalizedStep): string {
|
||||||
return [...new Set(pairs)].sort().join("+");
|
return [...new Set(pairs)].sort().join("+");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The setup signature of a pure-setup step — `<sorted technique keys>|<sorted
|
|
||||||
* temperature keys>`. Two steps with the same signature start the *same*
|
|
||||||
* appliance in the *same* state and can be pooled. The temperature part is
|
|
||||||
* `""` when no temperature was detected, which is a valid signature: two
|
|
||||||
* bare "préchauffer le four" steps then share it and merge (the optimistic
|
|
||||||
* default — an unknown temperature is assumed compatible). The appliance
|
|
||||||
* itself is deliberately not in the key (a `preheat` is the oven either
|
|
||||||
* way); its utensils are still unioned onto the pooled task.
|
|
||||||
*/
|
|
||||||
function setupSignature(step: NormalizedStep): string {
|
|
||||||
const techKeys = [...new Set(step.techSteps.map((ts) => ts.techStep.key))].sort().join("+");
|
|
||||||
const tempKeys = step.temperatures
|
|
||||||
.map(temperatureKey)
|
|
||||||
.filter((key) => key !== "")
|
|
||||||
.sort()
|
|
||||||
.join("+");
|
|
||||||
return `${techKeys}|${tempKeys}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Builds one {@link CookingTaskView} from a normalized step run as written. */
|
/** Builds one {@link CookingTaskView} from a normalized step run as written. */
|
||||||
function stepToTask(step: NormalizedStep): CookingTaskView {
|
function stepToTask(step: NormalizedStep): CookingTaskView {
|
||||||
return {
|
return {
|
||||||
|
|
@ -392,10 +292,6 @@ function stepToTask(step: NormalizedStep): CookingTaskView {
|
||||||
description: step.description,
|
description: step.description,
|
||||||
ingredients: step.ingredients,
|
ingredients: step.ingredients,
|
||||||
utensils: step.utensils,
|
utensils: step.utensils,
|
||||||
// Only a setup step (preheat/boil/…) carries a target temperature the
|
|
||||||
// plan should reason about; every other step leaves it `[]` even if its
|
|
||||||
// text happens to mention degrees.
|
|
||||||
temperatures: step.attention === "SETUP" ? step.temperatures : [],
|
|
||||||
sourceRecipes: [step.recipe],
|
sourceRecipes: [step.recipe],
|
||||||
originalSteps: [
|
originalSteps: [
|
||||||
{
|
{
|
||||||
|
|
@ -480,7 +376,6 @@ function extractMergedPrep(steps: NormalizedStep[]): CookingTaskView[] {
|
||||||
description: null,
|
description: null,
|
||||||
ingredients,
|
ingredients,
|
||||||
utensils,
|
utensils,
|
||||||
temperatures: [],
|
|
||||||
sourceRecipes,
|
sourceRecipes,
|
||||||
originalSteps,
|
originalSteps,
|
||||||
});
|
});
|
||||||
|
|
@ -489,99 +384,6 @@ function extractMergedPrep(steps: NormalizedStep[]): CookingTaskView[] {
|
||||||
return merged.sort((a, b) => a.id.localeCompare(b.id));
|
return merged.sort((a, b) => a.id.localeCompare(b.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Pools pure-setup steps with the same {@link setupSignature} (same action,
|
|
||||||
* same detected temperature) across two or more distinct recipes into one
|
|
||||||
* `merged-setup` {@link CookingTaskView}, marking each contributing step
|
|
||||||
* `absorbed`. Two `preheat` steps at *different* temperatures have different
|
|
||||||
* signatures and are therefore never pooled — {@link detectApplianceTemperatureConflicts}
|
|
||||||
* turns that into a phase note. A signature only one recipe needs is left
|
|
||||||
* untouched (still lands in mise-en-place as its own step task).
|
|
||||||
*
|
|
||||||
* Returns the merged tasks sorted by id.
|
|
||||||
*/
|
|
||||||
function extractMergedSetup(steps: NormalizedStep[]): CookingTaskView[] {
|
|
||||||
const bySignature = new Map<string, NormalizedStep[]>();
|
|
||||||
for (const step of steps) {
|
|
||||||
if (!step.isPureSetup) continue;
|
|
||||||
const signature = setupSignature(step);
|
|
||||||
const group = bySignature.get(signature);
|
|
||||||
if (group) group.push(step);
|
|
||||||
else bySignature.set(signature, [step]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const merged: CookingTaskView[] = [];
|
|
||||||
for (const group of bySignature.values()) {
|
|
||||||
const recipeIndexes = new Set(group.map((s) => s.recipeIndex));
|
|
||||||
if (recipeIndexes.size < 2) continue;
|
|
||||||
|
|
||||||
for (const step of group) step.absorbed = true;
|
|
||||||
|
|
||||||
const firstTech = group[0]?.techSteps[0]?.techStep ?? null;
|
|
||||||
const ingredients = unionIngredients(
|
|
||||||
group.flatMap((s) => s.techSteps.flatMap((ts) => ts.ingredients)),
|
|
||||||
);
|
|
||||||
const utensils = unionUtensils(group.flatMap((s) => s.utensils));
|
|
||||||
const temperatures = unionTemperatures(group.flatMap((s) => s.temperatures)).sort(
|
|
||||||
(a, b) => comparableTemp(a) - comparableTemp(b),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Distinct source recipes / original step texts, in input order.
|
|
||||||
const sourceRecipes: CookingSessionRecipeRef[] = [];
|
|
||||||
const seenRecipe = new Set<number>();
|
|
||||||
const originalSteps: CookingTaskView["originalSteps"] = [];
|
|
||||||
for (const step of [...group].sort((a, b) => a.recipeIndex - b.recipeIndex)) {
|
|
||||||
if (!seenRecipe.has(step.recipeIndex)) {
|
|
||||||
seenRecipe.add(step.recipeIndex);
|
|
||||||
sourceRecipes.push(step.recipe);
|
|
||||||
}
|
|
||||||
originalSteps.push({
|
|
||||||
recipeId: step.recipe.recipeId,
|
|
||||||
recipeName: step.recipe.name,
|
|
||||||
description: step.description,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const tempKey = temperatures.map(temperatureKey).join("+");
|
|
||||||
merged.push({
|
|
||||||
id: `setup:${firstTech ? firstTech.key : "setup"}:${tempKey || "any"}`,
|
|
||||||
kind: "merged-setup",
|
|
||||||
technique: firstTech,
|
|
||||||
description: null,
|
|
||||||
ingredients,
|
|
||||||
utensils,
|
|
||||||
temperatures,
|
|
||||||
sourceRecipes,
|
|
||||||
originalSteps,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return merged.sort((a, b) => a.id.localeCompare(b.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The kitchen has one oven: if the mise-en-place ends up with two or more
|
|
||||||
* `preheat` tasks at *distinct known* temperatures, they cannot run at once.
|
|
||||||
* Emits a single {@link CookingPhaseNoteView} listing those temperatures
|
|
||||||
* coolest-first so the web layer can say "enchaîner 180 °C puis 210 °C".
|
|
||||||
* A `preheat` with no detected temperature contributes nothing to the note
|
|
||||||
* (it merged optimistically with the other unknowns and we've no number to
|
|
||||||
* show).
|
|
||||||
*/
|
|
||||||
function detectApplianceTemperatureConflicts(
|
|
||||||
setupTasks: CookingTaskView[],
|
|
||||||
): CookingPhaseNoteView[] {
|
|
||||||
const preheats = setupTasks.filter((task) => task.technique?.key === "preheat");
|
|
||||||
const distinct = unionTemperatures(preheats.flatMap((task) => task.temperatures))
|
|
||||||
.filter((temperature) => temperatureKey(temperature) !== "")
|
|
||||||
.sort((a, b) => comparableTemp(a) - comparableTemp(b));
|
|
||||||
|
|
||||||
const technique = preheats.find((task) => task.technique !== null)?.technique;
|
|
||||||
if (distinct.length < 2 || !technique) return [];
|
|
||||||
|
|
||||||
return [{ kind: "appliance-temperature-conflict", technique, temperatures: distinct }];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Maps the input recipe list to its display legend, de-duplicating an exact `(recipeId, portions)` repeat. */
|
/** Maps the input recipe list to its display legend, de-duplicating an exact `(recipeId, portions)` repeat. */
|
||||||
function toRecipeLegend(recipes: OptimizerRecipeInput[]): CookingSessionRecipeRef[] {
|
function toRecipeLegend(recipes: OptimizerRecipeInput[]): CookingSessionRecipeRef[] {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
|
@ -604,11 +406,9 @@ function cookingPhaseKind(tasks: CookingTaskView[]): CookingPhaseKind {
|
||||||
* See the file header. Given the week's planned recipes (already resolved
|
* See the file header. Given the week's planned recipes (already resolved
|
||||||
* to reference views), returns the display legend plus the ordered phases:
|
* to reference views), returns the display legend plus the ordered phases:
|
||||||
*
|
*
|
||||||
* 1. **Mise en place** (`"mise-en-place"`) — all prep first (`merged-prep`
|
* 1. **Mise en place** (`"mise-en-place"`) — every `merged-prep` task, then
|
||||||
* tasks, then leftover pure-prep steps), then all setup (`merged-setup`
|
* every leftover pure-prep step, then every `SETUP` step. Omitted
|
||||||
* tasks and leftover `SETUP` steps, ordered coolest-first). Carries an
|
* entirely if it would be empty.
|
||||||
* `appliance-temperature-conflict` note when the week needs the oven at
|
|
||||||
* two or more temperatures. Omitted entirely if it would be empty.
|
|
||||||
* 2. **Cooking** (`"cooking"` / `"finishing"`) — the recipes interleaved:
|
* 2. **Cooking** (`"cooking"` / `"finishing"`) — the recipes interleaved:
|
||||||
* each phase pops the next remaining step of every recipe that still has
|
* each phase pops the next remaining step of every recipe that still has
|
||||||
* one (passive-cook steps first, so long cooks start early). A passive
|
* one (passive-cook steps first, so long cooks start early). A passive
|
||||||
|
|
@ -620,41 +420,21 @@ export function optimizeCookingPlan(recipes: OptimizerRecipeInput[]): OptimizeCo
|
||||||
const normalized = recipes.map((recipe, index) => normalizeRecipe(recipe, index));
|
const normalized = recipes.map((recipe, index) => normalizeRecipe(recipe, index));
|
||||||
const allSteps = normalized.flat();
|
const allSteps = normalized.flat();
|
||||||
|
|
||||||
// Merged-prep first, then merged-setup — each marks its contributing
|
|
||||||
// steps `absorbed`, so the leftover loop below only sees the solo ones.
|
|
||||||
const mergedPrep = extractMergedPrep(allSteps);
|
const mergedPrep = extractMergedPrep(allSteps);
|
||||||
const mergedSetup = extractMergedSetup(allSteps);
|
|
||||||
|
|
||||||
const phases: CookingPhaseView[] = [];
|
const phases: CookingPhaseView[] = [];
|
||||||
|
|
||||||
// Phase 0 — mise en place: all prep, then all setup. Keeping the two
|
// Phase 0 — mise en place.
|
||||||
// groups apart (rather than input order) lets the setup group be ordered
|
const miseTasks: CookingTaskView[] = [...mergedPrep];
|
||||||
// coolest-first without disturbing the prep order.
|
|
||||||
const prepTasks: CookingTaskView[] = [...mergedPrep];
|
|
||||||
const setupTasks: CookingTaskView[] = [...mergedSetup];
|
|
||||||
for (const step of allSteps) {
|
for (const step of allSteps) {
|
||||||
if (step.absorbed) continue;
|
if (step.absorbed) continue;
|
||||||
if (step.isPurePrep) {
|
if (step.isPurePrep || step.attention === "SETUP") {
|
||||||
prepTasks.push(stepToTask(step));
|
miseTasks.push(stepToTask(step));
|
||||||
step.absorbed = true; // consumed here, not again in the cooking loop
|
step.absorbed = true; // consumed here, not again in the cooking loop
|
||||||
} else if (step.attention === "SETUP") {
|
|
||||||
setupTasks.push(stepToTask(step));
|
|
||||||
step.absorbed = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Coolest oven first, so a temperature conflict reads "180 °C puis 210 °C";
|
|
||||||
// `id` as a stable tiebreaker keeps the plan deterministic.
|
|
||||||
setupTasks.sort((a, b) => setupTaskTemp(a) - setupTaskTemp(b) || a.id.localeCompare(b.id));
|
|
||||||
|
|
||||||
const miseTasks = [...prepTasks, ...setupTasks];
|
|
||||||
if (miseTasks.length > 0) {
|
if (miseTasks.length > 0) {
|
||||||
phases.push({
|
phases.push({ index: 0, kind: "mise-en-place", tasks: miseTasks, background: [] });
|
||||||
index: 0,
|
|
||||||
kind: "mise-en-place",
|
|
||||||
tasks: miseTasks,
|
|
||||||
background: [],
|
|
||||||
notes: detectApplianceTemperatureConflicts(setupTasks),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cooking phases — one "next step of each recipe" per phase. `hold` keeps
|
// Cooking phases — one "next step of each recipe" per phase. `hold` keeps
|
||||||
|
|
@ -710,9 +490,6 @@ export function optimizeCookingPlan(recipes: OptimizerRecipeInput[]): OptimizeCo
|
||||||
kind: cookingPhaseKind(tasks),
|
kind: cookingPhaseKind(tasks),
|
||||||
tasks,
|
tasks,
|
||||||
background,
|
background,
|
||||||
// Temperature conflicts only matter in mise-en-place, where all the
|
|
||||||
// preheats live; a cooking phase never carries a note today.
|
|
||||||
notes: [],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const step of phaseSteps) {
|
for (const step of phaseSteps) {
|
||||||
|
|
|
||||||
|
|
@ -25,28 +25,11 @@ export interface IntentServiceEntity {
|
||||||
kind: "technique" | "utensil";
|
kind: "technique" | "utensil";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** The full result of a `POST /v1/process` call — mirrors `ProcessResponse` (Python `schemas.py`). `intent` is `null` only when `locale` isn't one this service trains for, or `text` is blank; otherwise always a real `uid` (the Python service's `textcat` has no "None" sentinel, unlike node-nlp — see that service's README). */
|
||||||
* A temperature the service recognized in the text — mirrors
|
|
||||||
* `TemperaturePayload` (Python `schemas.py`), `gas_mark` camelCased to
|
|
||||||
* `gasMark`. A separate list from {@link IntentServiceEntity} (not another
|
|
||||||
* `kind`) because it carries structured fields that don't fit an entity's
|
|
||||||
* `uid`. At least one of `celsius` / `gasMark` / `qualitative` is non-null.
|
|
||||||
*/
|
|
||||||
export interface IntentServiceTemperature {
|
|
||||||
start: number;
|
|
||||||
end: number;
|
|
||||||
celsius: number | null;
|
|
||||||
gasMark: number | null;
|
|
||||||
qualitative: "low" | "medium" | "high" | null;
|
|
||||||
raw: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The full result of a `POST /v1/process` call — mirrors `ProcessResponse` (Python `schemas.py`). `intent` is `null` only when `locale` isn't one this service trains for, or `text` is blank; otherwise always a real `uid` (the Python service's `textcat` has no "None" sentinel, unlike node-nlp — see that service's README). `temperatures` is regex-extracted and independent of the model/training (empty when none, not omitted). */
|
|
||||||
export interface IntentServiceProcessResult {
|
export interface IntentServiceProcessResult {
|
||||||
entities: IntentServiceEntity[];
|
entities: IntentServiceEntity[];
|
||||||
intent: string | null;
|
intent: string | null;
|
||||||
score: number;
|
score: number;
|
||||||
temperatures: IntentServiceTemperature[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -105,26 +88,10 @@ export class IntentServiceClient {
|
||||||
*/
|
*/
|
||||||
public async process(locale: string, text: string): Promise<IntentServiceProcessResult> {
|
public async process(locale: string, text: string): Promise<IntentServiceProcessResult> {
|
||||||
try {
|
try {
|
||||||
// The wire shape uses `gas_mark` (Python field name); everything else
|
return await this._request("/v1/process", {
|
||||||
// is already camelCase / single-word. Map that one key at the
|
|
||||||
// boundary so the rest of `apps/api` only ever sees `gasMark`.
|
|
||||||
const wire = await this._request<
|
|
||||||
Omit<IntentServiceProcessResult, "temperatures"> & {
|
|
||||||
temperatures: (Omit<IntentServiceTemperature, "gasMark"> & { gas_mark: number | null })[];
|
|
||||||
}
|
|
||||||
>("/v1/process", {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ locale, text }),
|
body: JSON.stringify({ locale, text }),
|
||||||
});
|
});
|
||||||
return {
|
|
||||||
entities: wire.entities,
|
|
||||||
intent: wire.intent,
|
|
||||||
score: wire.score,
|
|
||||||
temperatures: (wire.temperatures ?? []).map(({ gas_mark, ...rest }) => ({
|
|
||||||
...rest,
|
|
||||||
gasMark: gas_mark,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import {
|
||||||
loadIngredientCatalog,
|
loadIngredientCatalog,
|
||||||
loadUnitCatalog,
|
loadUnitCatalog,
|
||||||
} from "./ingredient-matcher.js";
|
} from "./ingredient-matcher.js";
|
||||||
import { type IntentServiceTemperature, intentServiceClient } from "./intent-service-client.js";
|
import { intentServiceClient } from "./intent-service-client.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auto-detects which cooking techniques (`TechStep`) a free-text recipe
|
* Auto-detects which cooking techniques (`TechStep`) a free-text recipe
|
||||||
|
|
@ -114,7 +114,6 @@ export interface TechStepMatch {
|
||||||
contextEnd: number;
|
contextEnd: number;
|
||||||
ingredients: IngredientMention[];
|
ingredients: IngredientMention[];
|
||||||
utensils: UtensilMention[];
|
utensils: UtensilMention[];
|
||||||
temperatures: TemperatureMention[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -130,15 +129,6 @@ export interface UtensilMention {
|
||||||
end: number;
|
end: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A temperature the intent service's regex pass found (`temperatures` in
|
|
||||||
* `IntentServiceProcessResult`), attributed to whichever clause its span
|
|
||||||
* falls inside — same rule as {@link UtensilMention}. No DB id to resolve
|
|
||||||
* (unlike a technique/ingredient/utensil): the structured value *is* the
|
|
||||||
* data. Persisted verbatim as a `StepTechStepTemperature` row.
|
|
||||||
*/
|
|
||||||
export type TemperatureMention = IntentServiceTemperature;
|
|
||||||
|
|
||||||
/** A candidate technique mention found by NER — the raw material {@link splitIntoClauses} cuts a description around. */
|
/** A candidate technique mention found by NER — the raw material {@link splitIntoClauses} cuts a description around. */
|
||||||
export interface TechniqueCandidate {
|
export interface TechniqueCandidate {
|
||||||
/** Training-data `uid` this candidate's synonym belongs to (e.g. `"melt"`) — not yet resolved to a DB id at this stage. */
|
/** Training-data `uid` this candidate's synonym belongs to (e.g. `"melt"`) — not yet resolved to a DB id at this stage. */
|
||||||
|
|
@ -418,7 +408,6 @@ export class TechStepClassifierService {
|
||||||
.filter((entity) => entity.kind === "technique")
|
.filter((entity) => entity.kind === "technique")
|
||||||
.map((entity) => ({ uid: entity.uid, start: entity.start, end: entity.end }));
|
.map((entity) => ({ uid: entity.uid, start: entity.start, end: entity.end }));
|
||||||
const utensilEntities = nerResult.entities.filter((entity) => entity.kind === "utensil");
|
const utensilEntities = nerResult.entities.filter((entity) => entity.kind === "utensil");
|
||||||
const temperatureMentions = nerResult.temperatures;
|
|
||||||
|
|
||||||
const clauses = splitIntoClauses(description, candidates);
|
const clauses = splitIntoClauses(description, candidates);
|
||||||
const matches: TechStepMatch[] = [];
|
const matches: TechStepMatch[] = [];
|
||||||
|
|
@ -453,12 +442,6 @@ export class TechStepClassifierService {
|
||||||
: [{ utensilId, start: entity.start, end: entity.end }];
|
: [{ utensilId, start: entity.start, end: entity.end }];
|
||||||
});
|
});
|
||||||
|
|
||||||
// Temperatures carry no id to resolve — kept as-is, just attributed
|
|
||||||
// to the clause whose span contains them (same rule as utensils).
|
|
||||||
const temperatures: TemperatureMention[] = temperatureMentions.filter(
|
|
||||||
(temp) => temp.start >= clause.start && temp.end <= clause.end,
|
|
||||||
);
|
|
||||||
|
|
||||||
matches.push({
|
matches.push({
|
||||||
techStepId,
|
techStepId,
|
||||||
start: span.start,
|
start: span.start,
|
||||||
|
|
@ -467,7 +450,6 @@ export class TechStepClassifierService {
|
||||||
contextEnd: clause.end,
|
contextEnd: clause.end,
|
||||||
ingredients,
|
ingredients,
|
||||||
utensils,
|
utensils,
|
||||||
temperatures,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
import type { Prisma } from "@prisma/client";
|
|
||||||
import type { TechStepMatch } from "./tech-step-matcher.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Turns one step's {@link TechStepMatch}es into the Prisma nested-`create`
|
|
||||||
* payload for its `techSteps` relation — the technique row **and** all three
|
|
||||||
* of its clause-metadata child sets (`ingredients` / `utensils` /
|
|
||||||
* `temperatures`).
|
|
||||||
*
|
|
||||||
* The single source of truth for that shape. It used to be written out by
|
|
||||||
* hand in three places — `recipe.service.ts`'s `createRecipeInternal` and
|
|
||||||
* `updateRecipe`, plus `src/scripts/backfill-tech-steps.ts` — and they had
|
|
||||||
* drifted: only the create path wrote the metadata children, so **every
|
|
||||||
* recipe resaved through the UI, and every recipe the backfill touched,
|
|
||||||
* silently lost every ingredient / utensil / temperature the classifier had
|
|
||||||
* attributed to its steps**. That left `cooking-optimizer.ts`'s
|
|
||||||
* temperature-aware `preheat` grouping (the "don't preheat to 150 °C and
|
|
||||||
* 200 °C at once" note) with no data to work from on any recipe that wasn't
|
|
||||||
* a pristine, never-edited import. Building the payload once, here, is what
|
|
||||||
* stops the three call sites diverging again.
|
|
||||||
*
|
|
||||||
* `order` is each match's index in `matches` (0-based). Callers pass matches
|
|
||||||
* in reading order already; a caller that needs a different base (the
|
|
||||||
* backfill, which offsets past untouched `"manual"` rows) overrides `order`
|
|
||||||
* on the returned objects before handing them to Prisma.
|
|
||||||
*/
|
|
||||||
export function toStepTechStepCreateInputs(
|
|
||||||
matches: TechStepMatch[],
|
|
||||||
): Prisma.StepTechStepUncheckedCreateWithoutStepInput[] {
|
|
||||||
return matches.map((match, order) => ({
|
|
||||||
techStepId: match.techStepId,
|
|
||||||
start: match.start,
|
|
||||||
end: match.end,
|
|
||||||
contextStart: match.contextStart,
|
|
||||||
contextEnd: match.contextEnd,
|
|
||||||
order,
|
|
||||||
ingredients: {
|
|
||||||
create: match.ingredients.map((ingredient) => ({
|
|
||||||
ingredientId: ingredient.ingredientId,
|
|
||||||
quantity: ingredient.quantity,
|
|
||||||
unitId: ingredient.unitId,
|
|
||||||
start: ingredient.start,
|
|
||||||
end: ingredient.end,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
utensils: {
|
|
||||||
create: match.utensils.map((utensil) => ({
|
|
||||||
utensilId: utensil.utensilId,
|
|
||||||
start: utensil.start,
|
|
||||||
end: utensil.end,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
temperatures: {
|
|
||||||
create: match.temperatures.map((temperature) => ({
|
|
||||||
start: temperature.start,
|
|
||||||
end: temperature.end,
|
|
||||||
celsius: temperature.celsius,
|
|
||||||
gasMark: temperature.gasMark,
|
|
||||||
qualitative: temperature.qualitative,
|
|
||||||
raw: temperature.raw,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
import { parseCookie } from "cookie";
|
|
||||||
import type { Socket } from "socket.io";
|
|
||||||
import { env } from "../config/env.js";
|
|
||||||
import { resolveAdminFromToken } from "./require-admin.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `socket.io` handshake middleware guarding the admin WebSocket surface —
|
|
||||||
* the connection-level equivalent of {@link requireAdmin} (`require-
|
|
||||||
* admin.ts`). A `socket.io` handshake is a plain HTTP request that never
|
|
||||||
* runs through Express's own middleware stack (`cookie-parser` included),
|
|
||||||
* so the raw `Cookie` header has to be parsed by hand here with the
|
|
||||||
* `cookie` package (the same one `cookie-parser` itself wraps).
|
|
||||||
*
|
|
||||||
* Registered via `io.use(requireAdminSocket)` — `socket.io` calls every
|
|
||||||
* `io.use` middleware once per connection attempt, before `"connection"`
|
|
||||||
* fires, exactly like Express's per-request middleware chain. Calling
|
|
||||||
* `next()` with no argument admits the connection; calling it with an
|
|
||||||
* `Error` rejects it, which the client sees as a `connect_error` event
|
|
||||||
* (`socket.io-client` never even fires `"connect"` for it).
|
|
||||||
*
|
|
||||||
* On success, stashes the resolved admin on `socket.data.adminUser` —
|
|
||||||
* `socket.io`'s per-connection storage bag, the WebSocket equivalent of
|
|
||||||
* `res.locals` — for any handler that ends up needing it later.
|
|
||||||
*/
|
|
||||||
export async function requireAdminSocket(
|
|
||||||
socket: Socket,
|
|
||||||
next: (err?: Error) => void,
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
const cookieHeader = socket.handshake.headers.cookie;
|
|
||||||
const token = cookieHeader ? parseCookie(cookieHeader)[env.ADMIN_COOKIE_NAME] : undefined;
|
|
||||||
if (typeof token !== "string") {
|
|
||||||
throw new Error("Not authenticated");
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.data.adminUser = await resolveAdminFromToken(token);
|
|
||||||
next();
|
|
||||||
} catch {
|
|
||||||
// Covers everything resolveAdminFromToken can throw, plus a missing
|
|
||||||
// cookie — same "never distinguish the reason" posture as requireAdmin.
|
|
||||||
next(new Error("Not authenticated"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -19,50 +19,26 @@ export interface AdminLocals {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verifies an admin session JWT and resolves the `AdminUser` it belongs
|
* Express middleware guarding every `/admin/*` route — the operations app
|
||||||
* to — the transport-agnostic core both {@link requireAdmin} (HTTP) and
|
* (`apps/admin-web`) authenticating as an `AdminUser`. Reads the admin
|
||||||
* `requireAdminSocket` (`require-admin-socket.ts`, the WebSocket handshake
|
* session cookie (`ADMIN_COOKIE_NAME`, deliberately **not** the same cookie
|
||||||
* equivalent) build on; they only differ in *where* they read the raw
|
* as end-user sessions), verifies the JWT against `ADMIN_JWT_SECRET`
|
||||||
* cookie value from (`req.cookies`, already parsed by `cookie-parser`, vs
|
* (a different secret than `JWT_SECRET`), and re-checks `tokenVersion`
|
||||||
* a `socket.io` handshake's raw `Cookie` header, which isn't). Kept here
|
* against the database so a stateless JWT can still be invalidated
|
||||||
* rather than in `lib/admin-jwt.ts`, since it also does the DB
|
* server-side.
|
||||||
* `tokenVersion` re-check that file deliberately doesn't know about.
|
|
||||||
*
|
|
||||||
* @throws {Error} for any failure — malformed/expired JWT, unknown admin,
|
|
||||||
* stale `tokenVersion`, or no `ADMIN_JWT_SECRET` configured (from
|
|
||||||
* {@link verifyAdminToken}). Always the same generic message; callers
|
|
||||||
* map it to their own transport's "unauthenticated" shape.
|
|
||||||
*/
|
|
||||||
export async function resolveAdminFromToken(token: string): Promise<AdminUserView> {
|
|
||||||
const payload = verifyAdminToken(token);
|
|
||||||
const admin = await prisma.adminUser.findUnique({ where: { id: payload.adminUserId } });
|
|
||||||
|
|
||||||
if (!admin || admin.tokenVersion !== payload.tokenVersion) {
|
|
||||||
throw new Error("Not authenticated");
|
|
||||||
}
|
|
||||||
|
|
||||||
return toSafeAdmin(admin);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Express middleware guarding every `/admin/api/*` route — the operations
|
|
||||||
* UI (`apps/web`'s `/admin/*` pages) authenticating as an `AdminUser`.
|
|
||||||
* Reads the admin session cookie (`ADMIN_COOKIE_NAME`, deliberately
|
|
||||||
* **not** the same cookie as end-user sessions) and resolves it via
|
|
||||||
* {@link resolveAdminFromToken}.
|
|
||||||
*
|
*
|
||||||
* A completely separate mechanism from {@link requireAuth}, not layered on
|
* A completely separate mechanism from {@link requireAuth}, not layered on
|
||||||
* it: an end-user session token and an admin session token are never
|
* it: an end-user session token and an admin session token are never
|
||||||
* interchangeable in either direction.
|
* interchangeable in either direction.
|
||||||
*
|
*
|
||||||
* Fails closed: an unset `ADMIN_JWT_SECRET` (the default for any instance
|
* Fails closed: an unset `ADMIN_JWT_SECRET` (the default for any instance
|
||||||
* that doesn't run the admin app) makes {@link resolveAdminFromToken}
|
* that doesn't run the admin app) makes {@link verifyAdminToken} throw, so
|
||||||
* throw, so every request is rejected rather than the surface left open —
|
* every request is rejected rather than the surface left open — same
|
||||||
* same posture as `requireInternalWorker`.
|
* posture as `requireInternalWorker`.
|
||||||
*
|
*
|
||||||
* @throws {HttpError} `401 NOT_AUTHENTICATED` for any failure — missing
|
* @throws {HttpError} `401 NOT_AUTHENTICATED` for any failure — missing
|
||||||
* cookie or anything {@link resolveAdminFromToken} rejects on. Never
|
* cookie, malformed/expired JWT, unknown admin, stale tokenVersion, or
|
||||||
* distinguishes the reason.
|
* no secret configured. Never distinguishes the reason.
|
||||||
*/
|
*/
|
||||||
export async function requireAdmin(
|
export async function requireAdmin(
|
||||||
req: Request,
|
req: Request,
|
||||||
|
|
@ -75,13 +51,20 @@ export async function requireAdmin(
|
||||||
throw new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated");
|
throw new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated");
|
||||||
}
|
}
|
||||||
|
|
||||||
res.locals.adminUser = await resolveAdminFromToken(token);
|
const payload = verifyAdminToken(token);
|
||||||
|
const admin = await prisma.adminUser.findUnique({ where: { id: payload.adminUserId } });
|
||||||
|
|
||||||
|
if (!admin || admin.tokenVersion !== payload.tokenVersion) {
|
||||||
|
throw new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated");
|
||||||
|
}
|
||||||
|
|
||||||
|
res.locals.adminUser = toSafeAdmin(admin);
|
||||||
next();
|
next();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof HttpError) {
|
if (err instanceof HttpError) {
|
||||||
next(err);
|
next(err);
|
||||||
} else {
|
} else {
|
||||||
// Covers everything resolveAdminFromToken can throw.
|
// Covers jwt.verify failures and the unset-secret throw from verifyAdminToken.
|
||||||
next(new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated"));
|
next(new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,34 +13,16 @@ import { toSafeProfile } from "../lib/safe-profile.js";
|
||||||
* typed, no cast needed.
|
* typed, no cast needed.
|
||||||
*/
|
*/
|
||||||
export interface AuthLocals {
|
export interface AuthLocals {
|
||||||
/** The authenticated profile, resolved from the session cookie's JWT (or an `Authorization: Bearer` one, for cookie-less clients). */
|
/** The authenticated profile, resolved from the session cookie's JWT. */
|
||||||
userProfile: SafeUserProfile;
|
userProfile: SafeUserProfile;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Pulls the raw JWT out of an `Authorization: Bearer <jwt>` header, or
|
|
||||||
* returns `null` if it's absent or malformed. This is the fallback path for
|
|
||||||
* clients that can't rely on the `session` cookie — a Capacitor WebView,
|
|
||||||
* whose origin never receives a `SameSite=Lax` cookie (see `AuthResponse`
|
|
||||||
* in packages/shared). The cookie remains the primary mechanism; this only
|
|
||||||
* kicks in when the cookie isn't there.
|
|
||||||
*/
|
|
||||||
function readBearerToken(header: string | undefined): string | null {
|
|
||||||
if (!header?.startsWith("Bearer ")) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const token = header.slice("Bearer ".length).trim();
|
|
||||||
return token.length > 0 ? token : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Express middleware guarding routes that require an authenticated
|
* Express middleware guarding routes that require an authenticated
|
||||||
* profile. Reads the session cookie (falling back to an `Authorization:
|
* profile. Reads the session cookie, verifies the JWT, and re-checks
|
||||||
* Bearer <jwt>` header when there's no cookie — see {@link readBearerToken}),
|
* `tokenVersion` against the database — so a stateless JWT can still be
|
||||||
* verifies the JWT, and re-checks `tokenVersion` against the database — so a
|
* invalidated server-side (e.g. on password change / logout-everywhere,
|
||||||
* stateless JWT can still be invalidated server-side (e.g. on password
|
* once that feature exists) despite carrying no server-side session.
|
||||||
* change / logout-everywhere, once that feature exists) despite carrying no
|
|
||||||
* server-side session.
|
|
||||||
*
|
*
|
||||||
* On success, attaches the resolved profile to `res.locals.userProfile`
|
* On success, attaches the resolved profile to `res.locals.userProfile`
|
||||||
* (typed via {@link AuthLocals}) for downstream handlers to use.
|
* (typed via {@link AuthLocals}) for downstream handlers to use.
|
||||||
|
|
@ -51,9 +33,9 @@ function readBearerToken(header: string | undefined): string | null {
|
||||||
* project-wide ambient augmentation silently changing every `Request` in
|
* project-wide ambient augmentation silently changing every `Request` in
|
||||||
* the codebase, whether or not it went through this middleware.
|
* the codebase, whether or not it went through this middleware.
|
||||||
*
|
*
|
||||||
* @throws {HttpError} `401 NOT_AUTHENTICATED` for any failure — no cookie
|
* @throws {HttpError} `401 NOT_AUTHENTICATED` for any failure — missing
|
||||||
* and no usable Bearer header, malformed/expired JWT, unknown profile, or
|
* cookie, malformed/expired JWT, unknown profile, or stale tokenVersion.
|
||||||
* stale tokenVersion. Never distinguishes the reason to the client.
|
* Never distinguishes the reason to the client.
|
||||||
*/
|
*/
|
||||||
export async function requireAuth(
|
export async function requireAuth(
|
||||||
req: Request,
|
req: Request,
|
||||||
|
|
@ -61,11 +43,7 @@ export async function requireAuth(
|
||||||
next: NextFunction,
|
next: NextFunction,
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const cookieToken = req.cookies?.[env.AUTH_COOKIE_NAME];
|
const token = req.cookies?.[env.AUTH_COOKIE_NAME];
|
||||||
// Cookie first (the browser's mechanism, unchanged); `Authorization:
|
|
||||||
// Bearer` as a fallback for cookie-less clients — see readBearerToken.
|
|
||||||
const token =
|
|
||||||
typeof cookieToken === "string" ? cookieToken : readBearerToken(req.headers.authorization);
|
|
||||||
if (typeof token !== "string") {
|
if (typeof token !== "string") {
|
||||||
throw new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated");
|
throw new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { env } from "../../config/env.js";
|
||||||
import { type AdminLocals, requireAdmin } from "../../middlewares/require-admin.js";
|
import { type AdminLocals, requireAdmin } from "../../middlewares/require-admin.js";
|
||||||
import { adminLogin } from "./admin-auth.service.js";
|
import { adminLogin } from "./admin-auth.service.js";
|
||||||
|
|
||||||
/** Router mounted at `/admin/api/auth` (via `admin.routes.ts`) — admin login, logout, current-admin. No signup: admins are created out-of-band (`src/scripts/create-admin.ts`). */
|
/** Router mounted at `/admin/auth` (via `admin.routes.ts`) — admin login, logout, current-admin. No signup: admins are created out-of-band (`src/scripts/create-admin.ts`). */
|
||||||
export const adminAuthRouter = Router();
|
export const adminAuthRouter = Router();
|
||||||
|
|
||||||
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,5 @@
|
||||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||||
import {
|
import { listPlaceholdersQuerySchema, markPlaceholdersReviewedSchema } from "@batch-cooking/shared";
|
||||||
listPlaceholdersQuerySchema,
|
|
||||||
listTechStepPlaceholdersQuerySchema,
|
|
||||||
markPlaceholdersReviewedSchema,
|
|
||||||
markTechStepPlaceholdersReviewedSchema,
|
|
||||||
} from "@batch-cooking/shared";
|
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { requireAdmin } from "../../middlewares/require-admin.js";
|
import { requireAdmin } from "../../middlewares/require-admin.js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -12,17 +7,12 @@ import {
|
||||||
markPlaceholdersReviewed,
|
markPlaceholdersReviewed,
|
||||||
pruneOrphanPlaceholders,
|
pruneOrphanPlaceholders,
|
||||||
} from "./admin-catalog.service.js";
|
} from "./admin-catalog.service.js";
|
||||||
import {
|
|
||||||
listTechStepPlaceholders,
|
|
||||||
markTechStepPlaceholdersReviewed,
|
|
||||||
} from "./admin-tech-step-catalog.service.js";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Router mounted at `/admin/api/catalog` (via `admin.routes.ts`) — every route
|
* Router mounted at `/admin/catalog` (via `admin.routes.ts`) — every route
|
||||||
* behind {@link requireAdmin}. Surfaces the off-catalog "placeholders" users
|
* behind {@link requireAdmin}. Surfaces the off-catalog ingredient
|
||||||
* typed when the seeded catalog fell short — free-text ingredients on a
|
* "placeholders" users typed when the seeded catalog fell short, so a
|
||||||
* recipe line, and free-text techniques while correcting a step (issue #130)
|
* maintainer can see what's missing and mark gaps as handled.
|
||||||
* — so a maintainer can see what's missing and mark gaps as handled.
|
|
||||||
*/
|
*/
|
||||||
export const adminCatalogRouter = Router();
|
export const adminCatalogRouter = Router();
|
||||||
|
|
||||||
|
|
@ -52,29 +42,3 @@ adminCatalogRouter.post(
|
||||||
res.status(200).json(await pruneOrphanPlaceholders());
|
res.status(200).json(await pruneOrphanPlaceholders());
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- Missing techniques (issue #130) --------------------------------------
|
|
||||||
|
|
||||||
adminCatalogRouter.get(
|
|
||||||
"/tech-step-placeholders",
|
|
||||||
requireAdmin,
|
|
||||||
wrapAsyncHandler(async (req, res) => {
|
|
||||||
res
|
|
||||||
.status(200)
|
|
||||||
.json(await listTechStepPlaceholders(listTechStepPlaceholdersQuerySchema.parse(req.query)));
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
adminCatalogRouter.patch(
|
|
||||||
"/tech-step-placeholders/mark-reviewed",
|
|
||||||
requireAdmin,
|
|
||||||
wrapAsyncHandler(async (req, res) => {
|
|
||||||
res
|
|
||||||
.status(200)
|
|
||||||
.json(
|
|
||||||
await markTechStepPlaceholdersReviewed(
|
|
||||||
markTechStepPlaceholdersReviewedSchema.parse(req.body),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
import { HttpError } from "@batch-cooking/error-tools";
|
|
||||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
|
||||||
import {
|
|
||||||
createForgejoIssueSchema,
|
|
||||||
ErrorCode,
|
|
||||||
listContactMessagesQuerySchema,
|
|
||||||
} from "@batch-cooking/shared";
|
|
||||||
import { Router } from "express";
|
|
||||||
import { requireAdmin } from "../../middlewares/require-admin.js";
|
|
||||||
import {
|
|
||||||
createForgejoIssueFromMessage,
|
|
||||||
getForgejoStatus,
|
|
||||||
listContactMessages,
|
|
||||||
} from "./admin-contact.service.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Router mounted at `/admin/api/contact` (via `admin.routes.ts`) — behind
|
|
||||||
* {@link requireAdmin}. Backs the `/admin/messages` page: the read-only list
|
|
||||||
* of contact-form messages (issue #80), plus "create a Forgejo issue from
|
|
||||||
* this message" (issue #81). Still no message-mutation routes — the message
|
|
||||||
* itself stays read-only.
|
|
||||||
*/
|
|
||||||
export const adminContactRouter = Router();
|
|
||||||
|
|
||||||
adminContactRouter.get(
|
|
||||||
"/",
|
|
||||||
requireAdmin,
|
|
||||||
wrapAsyncHandler(async (req, res) => {
|
|
||||||
res
|
|
||||||
.status(200)
|
|
||||||
.json(await listContactMessages(listContactMessagesQuerySchema.parse(req.query)));
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Static path — declared before `/:id/...` so it's never parsed as an id.
|
|
||||||
adminContactRouter.get(
|
|
||||||
"/forgejo/status",
|
|
||||||
requireAdmin,
|
|
||||||
wrapAsyncHandler(async (_req, res) => {
|
|
||||||
res.status(200).json(getForgejoStatus());
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
adminContactRouter.post(
|
|
||||||
"/:id/forgejo-issue",
|
|
||||||
requireAdmin,
|
|
||||||
wrapAsyncHandler(async (req, res) => {
|
|
||||||
const id = Number(req.params.id);
|
|
||||||
if (!Number.isInteger(id) || id <= 0) {
|
|
||||||
throw new HttpError(
|
|
||||||
400,
|
|
||||||
ErrorCode.VALIDATION_ERROR,
|
|
||||||
`Not a valid contact message id: ${req.params.id}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const input = createForgejoIssueSchema.parse(req.body);
|
|
||||||
res.status(200).json(await createForgejoIssueFromMessage(id, input));
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
import { HttpError } from "@batch-cooking/error-tools";
|
|
||||||
import {
|
|
||||||
type ContactMessageAdminView,
|
|
||||||
type ContactMessageType,
|
|
||||||
type CreateForgejoIssueInput,
|
|
||||||
ErrorCode,
|
|
||||||
type ForgejoIntegrationStatus,
|
|
||||||
type ListContactMessagesQuery,
|
|
||||||
} from "@batch-cooking/shared";
|
|
||||||
import { prisma } from "../../db/prisma.js";
|
|
||||||
import { forgejo } from "../../lib/forgejo/forgejo.service.js";
|
|
||||||
|
|
||||||
/** How many messages `listContactMessages` returns per call — the page is a triage view, not an export (same cap/rationale as `CORRECTIONS_PAGE_SIZE`). */
|
|
||||||
const CONTACT_MESSAGES_PAGE_SIZE = 200;
|
|
||||||
|
|
||||||
const contactMessageInclude = {
|
|
||||||
author: { select: { id: true, firstName: true, lastName: true, email: true } },
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
type ContactMessageRow = Awaited<
|
|
||||||
ReturnType<
|
|
||||||
typeof prisma.contactMessage.findFirstOrThrow<{ include: typeof contactMessageInclude }>
|
|
||||||
>
|
|
||||||
>;
|
|
||||||
|
|
||||||
/** Shapes one Prisma `ContactMessage` row (with {@link contactMessageInclude}) into its admin view. */
|
|
||||||
function toContactMessageAdminView(row: ContactMessageRow): ContactMessageAdminView {
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
// Safe cast: `POST /contact` validates `type` against
|
|
||||||
// `CONTACT_MESSAGE_TYPES` before insert, so the column only ever holds
|
|
||||||
// one of those values.
|
|
||||||
type: row.type as ContactMessageType,
|
|
||||||
message: row.message,
|
|
||||||
pageUrl: row.pageUrl,
|
|
||||||
userAgent: row.userAgent,
|
|
||||||
appVersion: row.appVersion,
|
|
||||||
createdAt: row.createdAt.toISOString(),
|
|
||||||
// `null` when the sender's account was deleted after sending
|
|
||||||
// (`authorId` is `onDelete: SetNull`).
|
|
||||||
author: row.author,
|
|
||||||
forgejoIssueNumber: row.forgejoIssueNumber,
|
|
||||||
forgejoIssueUrl: row.forgejoIssueUrl,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every `ContactMessage` for the admin read view (`/admin/messages`, issue
|
|
||||||
* #80) — newest first, capped at {@link CONTACT_MESSAGES_PAGE_SIZE}, with an
|
|
||||||
* optional single-`type` filter. Read-only: this first version has no
|
|
||||||
* triage workflow (status / assignment / notes), so there's no update
|
|
||||||
* counterpart.
|
|
||||||
*/
|
|
||||||
export async function listContactMessages(
|
|
||||||
query: ListContactMessagesQuery,
|
|
||||||
): Promise<ContactMessageAdminView[]> {
|
|
||||||
try {
|
|
||||||
const rows = await prisma.contactMessage.findMany({
|
|
||||||
where: query.type ? { type: query.type } : {},
|
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
take: CONTACT_MESSAGES_PAGE_SIZE,
|
|
||||||
include: contactMessageInclude,
|
|
||||||
});
|
|
||||||
return rows.map(toContactMessageAdminView);
|
|
||||||
} catch (err) {
|
|
||||||
// Rethrown as-is — see `admin-tech-steps.service.ts`'s equivalent catch.
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether the "create a Forgejo issue from a message" feature is usable (issue #81) — drives whether the admin UI shows the button. */
|
|
||||||
export function getForgejoStatus(): ForgejoIntegrationStatus {
|
|
||||||
return { enabled: forgejo.isConfigured() };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a Forgejo issue from contact message `messageId` and records the
|
|
||||||
* link on the row (issue #81). The `title`/`body` come from the admin's
|
|
||||||
* pre-filled, edited modal.
|
|
||||||
*
|
|
||||||
* Order matters: every precondition is checked before Forgejo is called,
|
|
||||||
* and the row is only touched *after* Forgejo succeeds — a failed call
|
|
||||||
* leaves no partial state (no linked issue, button still shown).
|
|
||||||
*
|
|
||||||
* @throws {HttpError} `503 FORGEJO_NOT_CONFIGURED` (env not set),
|
|
||||||
* `404 CONTACT_MESSAGE_NOT_FOUND`, `409 CONTACT_MESSAGE_ALREADY_LINKED`
|
|
||||||
* (already has an issue), or `502 FORGEJO_REQUEST_FAILED` (the Forgejo
|
|
||||||
* call failed — DB untouched).
|
|
||||||
*/
|
|
||||||
export async function createForgejoIssueFromMessage(
|
|
||||||
messageId: number,
|
|
||||||
input: CreateForgejoIssueInput,
|
|
||||||
): Promise<ContactMessageAdminView> {
|
|
||||||
try {
|
|
||||||
if (!forgejo.isConfigured()) {
|
|
||||||
throw new HttpError(
|
|
||||||
503,
|
|
||||||
ErrorCode.FORGEJO_NOT_CONFIGURED,
|
|
||||||
"Forgejo integration is not configured",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = await prisma.contactMessage.findUnique({ where: { id: messageId } });
|
|
||||||
if (!existing) {
|
|
||||||
throw new HttpError(
|
|
||||||
404,
|
|
||||||
ErrorCode.CONTACT_MESSAGE_NOT_FOUND,
|
|
||||||
`Contact message ${messageId} not found`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (existing.forgejoIssueNumber !== null) {
|
|
||||||
throw new HttpError(
|
|
||||||
409,
|
|
||||||
ErrorCode.CONTACT_MESSAGE_ALREADY_LINKED,
|
|
||||||
`Contact message ${messageId} already links to Forgejo issue #${existing.forgejoIssueNumber}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Throws `502 FORGEJO_REQUEST_FAILED` on any failure — propagated below,
|
|
||||||
// before the row is touched.
|
|
||||||
const issue = await forgejo.createIssue({ title: input.title, body: input.body });
|
|
||||||
|
|
||||||
const updated = await prisma.contactMessage.update({
|
|
||||||
where: { id: messageId },
|
|
||||||
data: { forgejoIssueNumber: issue.number, forgejoIssueUrl: issue.htmlUrl },
|
|
||||||
include: contactMessageInclude,
|
|
||||||
});
|
|
||||||
return toContactMessageAdminView(updated);
|
|
||||||
} catch (err) {
|
|
||||||
// Rethrown as-is — see `admin-tech-steps.service.ts`'s equivalent catch.
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { Router } from "express";
|
||||||
import { requireAdmin } from "../../middlewares/require-admin.js";
|
import { requireAdmin } from "../../middlewares/require-admin.js";
|
||||||
import { getMetrics } from "./admin-metrics.service.js";
|
import { getMetrics } from "./admin-metrics.service.js";
|
||||||
|
|
||||||
/** Router mounted at `/admin/api/metrics` (via `admin.routes.ts`) — every route behind {@link requireAdmin}. */
|
/** Router mounted at `/admin/metrics` (via `admin.routes.ts`) — every route behind {@link requireAdmin}. */
|
||||||
export const adminMetricsRouter = Router();
|
export const adminMetricsRouter = Router();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
20
apps/api/src/modules/admin/admin-monitoring.routes.ts
Normal file
20
apps/api/src/modules/admin/admin-monitoring.routes.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||||
|
import { Router } from "express";
|
||||||
|
import { requireAdmin } from "../../middlewares/require-admin.js";
|
||||||
|
import { getMonitoring } from "./admin-monitoring.service.js";
|
||||||
|
|
||||||
|
/** Router mounted at `/admin/monitoring` (via `admin.routes.ts`) — behind {@link requireAdmin}. */
|
||||||
|
export const adminMonitoringRouter = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actively probes Postgres, the API, `tech-step-intent-service` and the LLM
|
||||||
|
* worker's heartbeat, returning a {@link MonitoringView} status board (see
|
||||||
|
* {@link getMonitoring}). No params; the admin UI polls it on an interval.
|
||||||
|
*/
|
||||||
|
adminMonitoringRouter.get(
|
||||||
|
"/",
|
||||||
|
requireAdmin,
|
||||||
|
wrapAsyncHandler(async (_req, res) => {
|
||||||
|
res.status(200).json(await getMonitoring());
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
@ -155,13 +155,7 @@ async function probeWorker(): Promise<ServiceHealthView> {
|
||||||
* Postgres, the API itself, `tech-step-intent-service` (`/health`), and the
|
* Postgres, the API itself, `tech-step-intent-service` (`/health`), and the
|
||||||
* LLM worker (via its stored heartbeat). Each probe is independent and
|
* LLM worker (via its stored heartbeat). Each probe is independent and
|
||||||
* bounded ({@link PROBE_TIMEOUT_MS}); one being `down` never fails the
|
* bounded ({@link PROBE_TIMEOUT_MS}); one being `down` never fails the
|
||||||
* others or this call as a whole.
|
* others or the endpoint.
|
||||||
*
|
|
||||||
* No HTTP route calls this directly (there was one, `GET
|
|
||||||
* /admin/api/monitoring` — removed) — `MonitoringBroadcastService`
|
|
||||||
* (`lib/monitoring-broadcast.service.ts`) is the only caller now, on a
|
|
||||||
* server-side timer, broadcasting the result over WebSocket instead of
|
|
||||||
* each admin's browser polling this on its own.
|
|
||||||
*/
|
*/
|
||||||
export async function getMonitoring(): Promise<MonitoringView> {
|
export async function getMonitoring(): Promise<MonitoringView> {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
import type {
|
|
||||||
ListTechStepPlaceholdersQuery,
|
|
||||||
MarkTechStepPlaceholdersReviewedInput,
|
|
||||||
TechStepPlaceholderView,
|
|
||||||
} from "@batch-cooking/shared";
|
|
||||||
import { prisma } from "../../db/prisma.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The missing-technique review (issue #130) — the tech-step counterpart of
|
|
||||||
* `admin-catalog.service.ts`'s off-catalog *ingredient* review. Kept a flat
|
|
||||||
* list rather than grouping near-duplicate spellings the way that one does:
|
|
||||||
* correcting a technique — let alone typing one the catalog is missing — is
|
|
||||||
* rare enough that a handful of raw rows is all a maintainer ever sees here.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Prisma `include` for the placeholder query — a bounded set of the `StepTechStep` rows pointing at this technique, each resolved to just its recipe id/name for the count + "seen in…" preview. */
|
|
||||||
const placeholderInclude = {
|
|
||||||
steps: {
|
|
||||||
take: 50,
|
|
||||||
include: { step: { select: { recipe: { select: { id: true, name: true } } } } },
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every placeholder `TechStep` (see `TechStep.isPlaceholder` in
|
|
||||||
* schema.prisma), most-impactful first (most distinct recipes, then name).
|
|
||||||
*
|
|
||||||
* `query.reviewed` selects which side to show: omitted / `"false"` drops
|
|
||||||
* rows already triaged (`reviewedAt` set) — the default working list;
|
|
||||||
* `"true"` keeps only those.
|
|
||||||
*/
|
|
||||||
export async function listTechStepPlaceholders(
|
|
||||||
query: ListTechStepPlaceholdersQuery,
|
|
||||||
): Promise<TechStepPlaceholderView[]> {
|
|
||||||
try {
|
|
||||||
const rows = await prisma.techStep.findMany({
|
|
||||||
where: { isPlaceholder: true },
|
|
||||||
include: placeholderInclude,
|
|
||||||
orderBy: { createdAt: "asc" },
|
|
||||||
});
|
|
||||||
|
|
||||||
const wantReviewed = query.reviewed === "true";
|
|
||||||
return rows
|
|
||||||
.filter((row) => (row.reviewedAt !== null) === wantReviewed)
|
|
||||||
.map((row) => {
|
|
||||||
const recipeById = new Map<number, string>();
|
|
||||||
for (const link of row.steps) {
|
|
||||||
recipeById.set(link.step.recipe.id, link.step.recipe.name);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
displayName: row.displayName ?? "",
|
|
||||||
createdAt: row.createdAt?.toISOString() ?? null,
|
|
||||||
reviewedAt: row.reviewedAt?.toISOString() ?? null,
|
|
||||||
recipeCount: recipeById.size,
|
|
||||||
sampleRecipes: [...recipeById.entries()].slice(0, 5).map(([id, name]) => ({ id, name })),
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.sort(
|
|
||||||
(a, b) => b.recipeCount - a.recipeCount || a.displayName.localeCompare(b.displayName, "fr"),
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see recipe.service.ts's equivalent catch comment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stamps `reviewedAt` on the given placeholder `TechStep` ids — a
|
|
||||||
* maintainer has seen this proposal (and, if it warranted it, promoted it
|
|
||||||
* into `reference-seed-data.ts` + `tech-step-training-data.ts` by hand; this
|
|
||||||
* endpoint never touches the catalog itself). Scoped to
|
|
||||||
* `isPlaceholder: true` so a stray real id is a silent no-op. Returns how
|
|
||||||
* many rows were actually stamped.
|
|
||||||
*/
|
|
||||||
export async function markTechStepPlaceholdersReviewed(
|
|
||||||
input: MarkTechStepPlaceholdersReviewedInput,
|
|
||||||
): Promise<{ reviewed: number }> {
|
|
||||||
try {
|
|
||||||
const { count } = await prisma.techStep.updateMany({
|
|
||||||
where: { id: { in: input.techStepIds }, isPlaceholder: true },
|
|
||||||
data: { reviewedAt: new Date() },
|
|
||||||
});
|
|
||||||
return { reviewed: count };
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see recipe.service.ts's equivalent catch comment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -18,7 +18,7 @@ import {
|
||||||
updateSuggestion,
|
updateSuggestion,
|
||||||
} from "./admin-tech-steps.service.js";
|
} from "./admin-tech-steps.service.js";
|
||||||
|
|
||||||
/** Router mounted at `/admin/api/tech-steps` (via `admin.routes.ts`) — every route behind {@link requireAdmin}. Correction/suggestion triage + the retrain trigger. */
|
/** Router mounted at `/admin/tech-steps` (via `admin.routes.ts`) — every route behind {@link requireAdmin}. Correction/suggestion triage + the retrain trigger. */
|
||||||
export const adminTechStepsRouter = Router();
|
export const adminTechStepsRouter = Router();
|
||||||
|
|
||||||
adminTechStepsRouter.get(
|
adminTechStepsRouter.get(
|
||||||
|
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
import { HttpError } from "@batch-cooking/error-tools";
|
|
||||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
|
||||||
import {
|
|
||||||
createTourScenarioSchema,
|
|
||||||
ErrorCode,
|
|
||||||
updateTourScenarioSchema,
|
|
||||||
} from "@batch-cooking/shared";
|
|
||||||
import { Router } from "express";
|
|
||||||
import { requireAdmin } from "../../middlewares/require-admin.js";
|
|
||||||
import {
|
|
||||||
createTourScenario,
|
|
||||||
deleteTourScenario,
|
|
||||||
listTourScenarios,
|
|
||||||
listTourTargets,
|
|
||||||
updateTourScenario,
|
|
||||||
} from "./admin-tours.service.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Router mounted at `/admin/api/tours` (via `admin.routes.ts`) — every
|
|
||||||
* route behind {@link requireAdmin}. Full CRUD over the guided-tour
|
|
||||||
* scenarios the public `GET /reference/tours` serves to the web tour
|
|
||||||
* engine (issues #42/#44).
|
|
||||||
*/
|
|
||||||
export const adminToursRouter = Router();
|
|
||||||
|
|
||||||
/** Express types a numeric route param `string | undefined`; parse + validate here. */
|
|
||||||
function parseId(value: string | undefined): number {
|
|
||||||
const id = Number(value);
|
|
||||||
if (!Number.isInteger(id) || id <= 0) {
|
|
||||||
throw new HttpError(400, ErrorCode.VALIDATION_ERROR, `Invalid tour scenario id "${value}"`);
|
|
||||||
}
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
adminToursRouter.get(
|
|
||||||
"/",
|
|
||||||
requireAdmin,
|
|
||||||
wrapAsyncHandler(async (_req, res) => {
|
|
||||||
res.status(200).json(await listTourScenarios());
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
/** The catalogue of valid highlight targets — for the editor's per-step target picker. */
|
|
||||||
adminToursRouter.get(
|
|
||||||
"/targets",
|
|
||||||
requireAdmin,
|
|
||||||
wrapAsyncHandler(async (_req, res) => {
|
|
||||||
res.status(200).json(await listTourTargets());
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
adminToursRouter.post(
|
|
||||||
"/",
|
|
||||||
requireAdmin,
|
|
||||||
wrapAsyncHandler(async (req, res) => {
|
|
||||||
const scenario = await createTourScenario(createTourScenarioSchema.parse(req.body));
|
|
||||||
res.status(201).json(scenario);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
adminToursRouter.patch(
|
|
||||||
"/:id",
|
|
||||||
requireAdmin,
|
|
||||||
wrapAsyncHandler(async (req, res) => {
|
|
||||||
const scenario = await updateTourScenario(
|
|
||||||
parseId(req.params.id),
|
|
||||||
updateTourScenarioSchema.parse(req.body),
|
|
||||||
);
|
|
||||||
res.status(200).json(scenario);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
adminToursRouter.delete(
|
|
||||||
"/:id",
|
|
||||||
requireAdmin,
|
|
||||||
wrapAsyncHandler(async (req, res) => {
|
|
||||||
await deleteTourScenario(parseId(req.params.id));
|
|
||||||
res.status(204).end();
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,175 +0,0 @@
|
||||||
import { HttpError } from "@batch-cooking/error-tools";
|
|
||||||
import {
|
|
||||||
type AdminTourScenarioView,
|
|
||||||
type CreateTourScenarioInput,
|
|
||||||
ErrorCode,
|
|
||||||
type TourTargetView,
|
|
||||||
type UpdateTourScenarioInput,
|
|
||||||
} from "@batch-cooking/shared";
|
|
||||||
import { Prisma } from "@prisma/client";
|
|
||||||
import { prisma } from "../../db/prisma.js";
|
|
||||||
|
|
||||||
/** Shapes one Prisma `TourScenario` (with its steps) into the admin view. */
|
|
||||||
function toAdminView(scenario: {
|
|
||||||
id: number;
|
|
||||||
key: string;
|
|
||||||
name: string;
|
|
||||||
enabled: boolean;
|
|
||||||
steps: Array<{
|
|
||||||
order: number;
|
|
||||||
route: string | null;
|
|
||||||
targetKey: string;
|
|
||||||
title: string;
|
|
||||||
content: string;
|
|
||||||
}>;
|
|
||||||
}): AdminTourScenarioView {
|
|
||||||
return {
|
|
||||||
id: scenario.id,
|
|
||||||
key: scenario.key,
|
|
||||||
name: scenario.name,
|
|
||||||
enabled: scenario.enabled,
|
|
||||||
steps: scenario.steps
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => a.order - b.order)
|
|
||||||
.map((step) => ({
|
|
||||||
order: step.order,
|
|
||||||
route: step.route,
|
|
||||||
targetKey: step.targetKey,
|
|
||||||
title: step.title,
|
|
||||||
content: step.content,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Every tour scenario — enabled *and* disabled — with its ordered steps. */
|
|
||||||
export async function listTourScenarios(): Promise<AdminTourScenarioView[]> {
|
|
||||||
try {
|
|
||||||
const scenarios = await prisma.tourScenario.findMany({
|
|
||||||
orderBy: { key: "asc" },
|
|
||||||
include: { steps: { orderBy: { order: "asc" } } },
|
|
||||||
});
|
|
||||||
return scenarios.map(toAdminView);
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see the repo's service-layer try/catch convention
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The catalogue of valid highlight targets, for the editor's target picker. */
|
|
||||||
export async function listTourTargets(): Promise<TourTargetView[]> {
|
|
||||||
try {
|
|
||||||
return await prisma.tourTarget.findMany({ orderBy: { key: "asc" } });
|
|
||||||
} catch (err) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates an empty scenario (no steps yet — the editor adds them via
|
|
||||||
* {@link updateTourScenario}).
|
|
||||||
*
|
|
||||||
* @throws {HttpError} `409 TOUR_KEY_ALREADY_IN_USE` if `key` is taken.
|
|
||||||
*/
|
|
||||||
export async function createTourScenario(
|
|
||||||
input: CreateTourScenarioInput,
|
|
||||||
): Promise<AdminTourScenarioView> {
|
|
||||||
try {
|
|
||||||
const scenario = await prisma.tourScenario.create({
|
|
||||||
data: { key: input.key, name: input.name },
|
|
||||||
include: { steps: true },
|
|
||||||
});
|
|
||||||
return toAdminView(scenario);
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") {
|
|
||||||
throw new HttpError(
|
|
||||||
409,
|
|
||||||
ErrorCode.TOUR_KEY_ALREADY_IN_USE,
|
|
||||||
`A tour scenario with key "${input.key}" already exists`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates a scenario's `name`/`enabled` and **replaces its entire step
|
|
||||||
* list** (delete-all then recreate with `order` = array index) — the same
|
|
||||||
* block-replace `updateRecipe` uses for a recipe's steps, not a partial
|
|
||||||
* patch. `key` is immutable.
|
|
||||||
*
|
|
||||||
* @throws {HttpError} `404 TOUR_NOT_FOUND` if `id` matches no scenario.
|
|
||||||
* @throws {HttpError} `404 TOUR_TARGET_NOT_FOUND` if a step's `targetKey`
|
|
||||||
* isn't a seeded `TourTarget`.
|
|
||||||
*/
|
|
||||||
export async function updateTourScenario(
|
|
||||||
id: number,
|
|
||||||
input: UpdateTourScenarioInput,
|
|
||||||
): Promise<AdminTourScenarioView> {
|
|
||||||
try {
|
|
||||||
const existing = await prisma.tourScenario.findUnique({ where: { id } });
|
|
||||||
if (!existing) {
|
|
||||||
throw new HttpError(404, ErrorCode.TOUR_NOT_FOUND, `No tour scenario with id ${id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetKeys = [...new Set(input.steps.map((step) => step.targetKey))];
|
|
||||||
if (targetKeys.length > 0) {
|
|
||||||
const known = await prisma.tourTarget.findMany({
|
|
||||||
where: { key: { in: targetKeys } },
|
|
||||||
select: { key: true },
|
|
||||||
});
|
|
||||||
const knownKeys = new Set(known.map((target) => target.key));
|
|
||||||
const unknown = targetKeys.find((key) => !knownKeys.has(key));
|
|
||||||
if (unknown !== undefined) {
|
|
||||||
throw new HttpError(
|
|
||||||
404,
|
|
||||||
ErrorCode.TOUR_TARGET_NOT_FOUND,
|
|
||||||
`No tour target with key "${unknown}"`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [, , scenario] = await prisma.$transaction([
|
|
||||||
prisma.tourScenario.update({
|
|
||||||
where: { id },
|
|
||||||
data: { name: input.name, enabled: input.enabled },
|
|
||||||
}),
|
|
||||||
prisma.tourScenarioStep.deleteMany({ where: { scenarioId: id } }),
|
|
||||||
prisma.tourScenario.update({
|
|
||||||
where: { id },
|
|
||||||
data: {
|
|
||||||
steps: {
|
|
||||||
create: input.steps.map((step, index) => ({
|
|
||||||
order: index,
|
|
||||||
route: step.route,
|
|
||||||
targetKey: step.targetKey,
|
|
||||||
title: step.title,
|
|
||||||
content: step.content,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
include: { steps: { orderBy: { order: "asc" } } },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
return toAdminView(scenario);
|
|
||||||
} catch (err) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes a scenario. Its steps and every user's `TourProgress` row for it
|
|
||||||
* cascade away (a scenario that no longer exists has no "seen" state worth
|
|
||||||
* keeping).
|
|
||||||
*
|
|
||||||
* @throws {HttpError} `404 TOUR_NOT_FOUND` if `id` matches no scenario.
|
|
||||||
*/
|
|
||||||
export async function deleteTourScenario(id: number): Promise<void> {
|
|
||||||
try {
|
|
||||||
const existing = await prisma.tourScenario.findUnique({ where: { id } });
|
|
||||||
if (!existing) {
|
|
||||||
throw new HttpError(404, ErrorCode.TOUR_NOT_FOUND, `No tour scenario with id ${id}`);
|
|
||||||
}
|
|
||||||
await prisma.tourScenario.delete({ where: { id } });
|
|
||||||
} catch (err) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
|
||||||
import { adminCreateUserSchema } from "@batch-cooking/shared";
|
|
||||||
import { Router } from "express";
|
|
||||||
import { requireAdmin } from "../../middlewares/require-admin.js";
|
|
||||||
import { createUserProfile, listUsers } from "./admin-users.service.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Router mounted at `/admin/api/users` (via `admin.routes.ts`) — every route
|
|
||||||
* behind {@link requireAdmin}. Backs the `/admin/utilisateurs` page: a
|
|
||||||
* read-only list of end-user accounts and a "create an account by hand"
|
|
||||||
* form. The server generates a one-time temporary password on create and
|
|
||||||
* returns it in the response (see `admin-users.service.ts`).
|
|
||||||
*/
|
|
||||||
export const adminUsersRouter = Router();
|
|
||||||
|
|
||||||
adminUsersRouter.get(
|
|
||||||
"/",
|
|
||||||
requireAdmin,
|
|
||||||
wrapAsyncHandler(async (_req, res) => {
|
|
||||||
res.status(200).json(await listUsers());
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
adminUsersRouter.post(
|
|
||||||
"/",
|
|
||||||
requireAdmin,
|
|
||||||
wrapAsyncHandler(async (req, res) => {
|
|
||||||
const input = adminCreateUserSchema.parse(req.body);
|
|
||||||
res.status(201).json(await createUserProfile(input));
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,145 +0,0 @@
|
||||||
import { randomInt } from "node:crypto";
|
|
||||||
import { HttpError } from "@batch-cooking/error-tools";
|
|
||||||
import {
|
|
||||||
type AdminCreateUserInput,
|
|
||||||
type AdminUserCreatedView,
|
|
||||||
type AdminUserListItemView,
|
|
||||||
ErrorCode,
|
|
||||||
} from "@batch-cooking/shared";
|
|
||||||
import argon2 from "argon2";
|
|
||||||
import { env } from "../../config/env.js";
|
|
||||||
import { prisma } from "../../db/prisma.js";
|
|
||||||
import { analytics } from "../../lib/analytics.service.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How many accounts `listUsers` returns per call. Like `CONTACT_MESSAGES_PAGE_SIZE`
|
|
||||||
* this is a triage view, not an export — the admin creates/looks up a handful
|
|
||||||
* of accounts, it never needs the whole table paginated.
|
|
||||||
*/
|
|
||||||
const USERS_PAGE_SIZE = 200;
|
|
||||||
|
|
||||||
// Same reasoning as `admin-auth.service.ts`'s / `auth.service.ts`'s `hashOptions`:
|
|
||||||
// argon2's real defaults are deliberately expensive; the test suite hashes
|
|
||||||
// against throwaway data many times per run, so a cheaper cost keeps it fast
|
|
||||||
// without weakening anything real. Never applies outside NODE_ENV=test.
|
|
||||||
const testHashOptions = { memoryCost: 8192, timeCost: 2, parallelism: 1 };
|
|
||||||
const hashOptions = env.NODE_ENV === "test" ? testHashOptions : undefined;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Charset for {@link generateTemporaryPassword} — upper/lower letters + digits,
|
|
||||||
* minus the visually ambiguous ones (`0`/`O`/`1`/`l`/`I`), same spirit as
|
|
||||||
* `house.service.ts`'s `INVITE_CODE_CHARS`: this string is read off one screen
|
|
||||||
* and typed into another (or pasted, but not always).
|
|
||||||
*/
|
|
||||||
const TEMP_PASSWORD_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789";
|
|
||||||
|
|
||||||
/** Length of a generated temporary password — comfortably above `signupSchema`'s 8-char floor. */
|
|
||||||
const TEMP_PASSWORD_LENGTH = 16;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A fresh random temporary password for an admin-created account. Uses
|
|
||||||
* `node:crypto`'s `randomInt` (not `Math.random`) exactly like
|
|
||||||
* `generateInviteCode()` — this value guards a real account until its owner
|
|
||||||
* changes it. Returned to the operator once and never stored in plain text.
|
|
||||||
*/
|
|
||||||
function generateTemporaryPassword(): string {
|
|
||||||
let password = "";
|
|
||||||
for (let i = 0; i < TEMP_PASSWORD_LENGTH; i++) {
|
|
||||||
password += TEMP_PASSWORD_CHARS[randomInt(TEMP_PASSWORD_CHARS.length)];
|
|
||||||
}
|
|
||||||
return password;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A `UserProfile` row with its household name joined in — the shape {@link toListItem} maps from. */
|
|
||||||
type UserRow = Awaited<
|
|
||||||
ReturnType<
|
|
||||||
typeof prisma.userProfile.findFirstOrThrow<{
|
|
||||||
include: { house: { select: { name: true } } };
|
|
||||||
}>
|
|
||||||
>
|
|
||||||
>;
|
|
||||||
|
|
||||||
/** Shapes one Prisma `UserProfile` row (with its `house` name) into the admin list view — drops `passwordHash`/`tokenVersion`, resolves the household name. */
|
|
||||||
function toListItem(row: UserRow): AdminUserListItemView {
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
firstName: row.firstName,
|
|
||||||
lastName: row.lastName,
|
|
||||||
email: row.email,
|
|
||||||
householdName: row.house?.name ?? null,
|
|
||||||
createdAt: row.createdAt.toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every end-user account for the admin `/admin/utilisateurs` list — newest
|
|
||||||
* first, capped at {@link USERS_PAGE_SIZE}. Read-only: this first version has
|
|
||||||
* no edit/disable/delete (deleting an account is irreversible and stays a
|
|
||||||
* deliberate self-service action in `AccountSettingsPage`).
|
|
||||||
*/
|
|
||||||
export async function listUsers(): Promise<AdminUserListItemView[]> {
|
|
||||||
try {
|
|
||||||
const rows = await prisma.userProfile.findMany({
|
|
||||||
// `id` as a tiebreaker so the order is total even if two accounts share
|
|
||||||
// a `createdAt` (bulk-provisioned in the same tick) — `id` ascends with
|
|
||||||
// `createdAt` anyway.
|
|
||||||
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
|
||||||
take: USERS_PAGE_SIZE,
|
|
||||||
include: { house: { select: { name: true } } },
|
|
||||||
});
|
|
||||||
return rows.map(toListItem);
|
|
||||||
} catch (err) {
|
|
||||||
// Rethrown as-is — `wrapAsyncHandler`/the error middleware handles it,
|
|
||||||
// this layer just isn't allowed a bare `await`. Same as `admin-contact.service.ts`.
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Provisions a new end-user account by hand and returns it together with the
|
|
||||||
* one-time temporary password the caller must relay to the user. Mirrors
|
|
||||||
* `auth.service.ts`'s `signup` for the parts that matter (email-uniqueness
|
|
||||||
* check, argon2 hash, `UserProfile` row with `houseId`/`dietId` left `null`)
|
|
||||||
* but issues **no session token** — the operator isn't logging in as this
|
|
||||||
* user — and generates the password instead of accepting one.
|
|
||||||
*
|
|
||||||
* @throws {HttpError} `409 EMAIL_ALREADY_IN_USE` if the email is already taken.
|
|
||||||
*/
|
|
||||||
export async function createUserProfile(
|
|
||||||
input: AdminCreateUserInput,
|
|
||||||
): Promise<AdminUserCreatedView> {
|
|
||||||
try {
|
|
||||||
const existing = await prisma.userProfile.findUnique({ where: { email: input.email } });
|
|
||||||
if (existing) {
|
|
||||||
throw new HttpError(409, ErrorCode.EMAIL_ALREADY_IN_USE, "Email already in use");
|
|
||||||
}
|
|
||||||
|
|
||||||
const temporaryPassword = generateTemporaryPassword();
|
|
||||||
const passwordHash = await argon2.hash(temporaryPassword, hashOptions);
|
|
||||||
|
|
||||||
const profile = await prisma.userProfile.create({
|
|
||||||
// No household/diet here, same as `signup` — the user picks those in the
|
|
||||||
// onboarding wizard after their first login.
|
|
||||||
data: {
|
|
||||||
firstName: input.firstName,
|
|
||||||
lastName: input.lastName,
|
|
||||||
email: input.email,
|
|
||||||
passwordHash,
|
|
||||||
},
|
|
||||||
include: { house: { select: { name: true } } },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fire-and-forget, one line, no migration (see `analytics.service.ts`). A
|
|
||||||
// distinct type from `user.signup` so the dashboard can tell hand-provisioned
|
|
||||||
// accounts apart; `actorType: "system"` because the actor is an operator, not
|
|
||||||
// the new user (and `AnalyticsEvent.actorId` only ever means a `UserProfile.id`).
|
|
||||||
analytics.recordEvent("admin.user_created", {
|
|
||||||
actorType: "system",
|
|
||||||
context: { userId: profile.id },
|
|
||||||
});
|
|
||||||
|
|
||||||
return { user: toListItem(profile), temporaryPassword };
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see listUsers()'s catch comment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +1,22 @@
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { adminAuthRouter } from "./admin-auth.routes.js";
|
import { adminAuthRouter } from "./admin-auth.routes.js";
|
||||||
import { adminCatalogRouter } from "./admin-catalog.routes.js";
|
import { adminCatalogRouter } from "./admin-catalog.routes.js";
|
||||||
import { adminContactRouter } from "./admin-contact.routes.js";
|
|
||||||
import { adminMetricsRouter } from "./admin-metrics.routes.js";
|
import { adminMetricsRouter } from "./admin-metrics.routes.js";
|
||||||
|
import { adminMonitoringRouter } from "./admin-monitoring.routes.js";
|
||||||
import { adminTechStepsRouter } from "./admin-tech-steps.routes.js";
|
import { adminTechStepsRouter } from "./admin-tech-steps.routes.js";
|
||||||
import { adminToursRouter } from "./admin-tours.routes.js";
|
|
||||||
import { adminUsersRouter } from "./admin-users.routes.js";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Aggregator for the admin application's API surface, mounted at
|
* Aggregator for the admin application's API surface, mounted at `/admin`
|
||||||
* `/admin/api` in `app.ts` (not bare `/admin` — see that mount point's own
|
* in `app.ts`. Every sub-router here is for `apps/admin-web` only —
|
||||||
* comment for why: it would collide with apps/web's `/admin/*` page
|
* `/admin/auth` is public (login), everything added later
|
||||||
* routes). Every sub-router here backs one of those pages —
|
* (`/admin/metrics`, `/admin/monitoring`, `/admin/tech-steps/*`,
|
||||||
* `/admin/api/auth` is public (login), everything added later
|
* `/admin/catalog/*`) sits behind `requireAdmin`
|
||||||
* (`/admin/api/metrics`, `/admin/api/tech-steps/*`, `/admin/api/catalog/*`)
|
* (`middlewares/require-admin.ts`).
|
||||||
* sits behind `requireAdmin` (`middlewares/require-admin.ts`).
|
|
||||||
*
|
|
||||||
* Monitoring has **no** REST route here on purpose — it's pushed over
|
|
||||||
* WebSocket instead (`lib/monitoring-broadcast.service.ts`, wired in
|
|
||||||
* `server.ts`), not requested per-page-load. See that service's doc
|
|
||||||
* comment for the full mechanism.
|
|
||||||
*/
|
*/
|
||||||
export const adminRouter = Router();
|
export const adminRouter = Router();
|
||||||
|
|
||||||
adminRouter.use("/auth", adminAuthRouter);
|
adminRouter.use("/auth", adminAuthRouter);
|
||||||
adminRouter.use("/metrics", adminMetricsRouter);
|
adminRouter.use("/metrics", adminMetricsRouter);
|
||||||
|
adminRouter.use("/monitoring", adminMonitoringRouter);
|
||||||
adminRouter.use("/tech-steps", adminTechStepsRouter);
|
adminRouter.use("/tech-steps", adminTechStepsRouter);
|
||||||
adminRouter.use("/catalog", adminCatalogRouter);
|
adminRouter.use("/catalog", adminCatalogRouter);
|
||||||
adminRouter.use("/tours", adminToursRouter);
|
|
||||||
adminRouter.use("/contact", adminContactRouter);
|
|
||||||
adminRouter.use("/users", adminUsersRouter);
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,5 @@
|
||||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||||
import {
|
import { deleteAccountSchema, loginSchema, signupSchema } from "@batch-cooking/shared";
|
||||||
type AuthResponse,
|
|
||||||
deleteAccountSchema,
|
|
||||||
loginSchema,
|
|
||||||
signupSchema,
|
|
||||||
} from "@batch-cooking/shared";
|
|
||||||
import type { CookieOptions, Response } from "express";
|
import type { CookieOptions, Response } from "express";
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { env } from "../../config/env.js";
|
import { env } from "../../config/env.js";
|
||||||
|
|
@ -48,11 +43,7 @@ authRouter.post(
|
||||||
const input = signupSchema.parse(req.body);
|
const input = signupSchema.parse(req.body);
|
||||||
const { profile, token } = await signup(input);
|
const { profile, token } = await signup(input);
|
||||||
res.cookie(env.AUTH_COOKIE_NAME, token, cookieOptions);
|
res.cookie(env.AUTH_COOKIE_NAME, token, cookieOptions);
|
||||||
// Token is echoed in the body too, not just the cookie — see
|
res.status(201).json(profile);
|
||||||
// `AuthResponse` (packages/shared) for why (Capacitor WebView origin
|
|
||||||
// can't receive a SameSite cookie). Purely additive: every existing
|
|
||||||
// profile field stays exactly where it was.
|
|
||||||
res.status(201).json({ ...profile, token } satisfies AuthResponse);
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -63,7 +54,7 @@ authRouter.post(
|
||||||
const input = loginSchema.parse(req.body);
|
const input = loginSchema.parse(req.body);
|
||||||
const { profile, token } = await login(input);
|
const { profile, token } = await login(input);
|
||||||
res.cookie(env.AUTH_COOKIE_NAME, token, cookieOptions);
|
res.cookie(env.AUTH_COOKIE_NAME, token, cookieOptions);
|
||||||
res.status(200).json({ ...profile, token } satisfies AuthResponse);
|
res.status(200).json(profile);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
|
||||||
import { submitContactMessageSchema } from "@batch-cooking/shared";
|
|
||||||
import { Router } from "express";
|
|
||||||
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
|
||||||
import { submitContactMessage } from "./contact.service.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Router mounted at `/contact` in app.ts (issue #80). A single endpoint —
|
|
||||||
* `POST /` — for the in-app "signaler un bug / suggestion" form. Requires a
|
|
||||||
* session: only signed-in users can send a message, so every report is
|
|
||||||
* attributable (see `ContactMessage.authorId`). The admin-side read of
|
|
||||||
* these rows lives entirely in the admin router, not here.
|
|
||||||
*/
|
|
||||||
export const contactRouter = Router();
|
|
||||||
|
|
||||||
contactRouter.post(
|
|
||||||
"/",
|
|
||||||
requireAuth,
|
|
||||||
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
|
||||||
const input = submitContactMessageSchema.parse(req.body);
|
|
||||||
// Read the UA from the request, never from the body — the schema omits
|
|
||||||
// it on purpose so it can't be spoofed. Header can be an array or
|
|
||||||
// absent; normalize to `string | null`.
|
|
||||||
const rawUserAgent = req.headers["user-agent"];
|
|
||||||
const userAgent =
|
|
||||||
typeof rawUserAgent === "string" && rawUserAgent.length > 0 ? rawUserAgent : null;
|
|
||||||
await submitContactMessage(res.locals.userProfile.id, input, userAgent);
|
|
||||||
// 204, not 201-with-a-body: the form has nothing to do with the stored
|
|
||||||
// row, and the shared `ApiClient._request` treats only 204 as bodyless
|
|
||||||
// (a 201 with an empty body makes its `response.json()` throw). Same
|
|
||||||
// "mutation returns nothing" shape as `POST /auth/logout`.
|
|
||||||
res.status(204).end();
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
import type { SubmitContactMessageInput } from "@batch-cooking/shared";
|
|
||||||
import { prisma } from "../../db/prisma.js";
|
|
||||||
import { analytics } from "../../lib/analytics.service.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Persists one contact-form submission (issue #80). Write-only from the
|
|
||||||
* user's side — there is no read/update/delete counterpart here: admins
|
|
||||||
* consult these rows through the admin app (`admin-contact.service.ts`, PR
|
|
||||||
* 2), never this module.
|
|
||||||
*
|
|
||||||
* `userAgent` comes straight from the request header, never the body — the
|
|
||||||
* shared `submitContactMessageSchema` deliberately omits it so the client
|
|
||||||
* can't spoof it. `pageUrl` / `appVersion` are client-supplied context and
|
|
||||||
* default to `null` when the (older, or non-web) client didn't send them.
|
|
||||||
*
|
|
||||||
* Returns nothing: the form only needs to know the write succeeded (a
|
|
||||||
* `201`), not the stored row.
|
|
||||||
*/
|
|
||||||
export async function submitContactMessage(
|
|
||||||
authorId: number,
|
|
||||||
input: SubmitContactMessageInput,
|
|
||||||
userAgent: string | null,
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
await prisma.contactMessage.create({
|
|
||||||
data: {
|
|
||||||
authorId,
|
|
||||||
type: input.type,
|
|
||||||
message: input.message,
|
|
||||||
pageUrl: input.pageUrl ?? null,
|
|
||||||
userAgent,
|
|
||||||
appVersion: input.appVersion ?? null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
// Fire-and-forget (see AnalyticsService's contract) — `admin-metrics`
|
|
||||||
// aggregates `AnalyticsEvent` by `type` generically, so this shows up in
|
|
||||||
// the dashboard series with no extra code; `context.type` lets it be
|
|
||||||
// broken down by bug/suggestion/other.
|
|
||||||
analytics.recordEvent("contact.submitted", {
|
|
||||||
actorType: "user",
|
|
||||||
actorId: authorId,
|
|
||||||
context: { type: input.type },
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
// Rethrown as-is — `wrapAsyncHandler` / the error middleware (which
|
|
||||||
// already logs it) handles it; this service layer just isn't allowed a
|
|
||||||
// bare `await` per the repo's async/try-catch convention.
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -22,11 +22,6 @@ import { toIngredientView, toUnitView } from "../recipe/recipe.service.js";
|
||||||
function cookingSessionPlanningInclude() {
|
function cookingSessionPlanningInclude() {
|
||||||
return {
|
return {
|
||||||
items: {
|
items: {
|
||||||
// A "leftovers" item (`sourcePlanningItemId` set) reserves part of an
|
|
||||||
// already-cooked batch — it has nothing to cook, so it must not enter
|
|
||||||
// the optimized cooking plan (issue #34). Excluded at the query so it
|
|
||||||
// never reaches `toOptimizerRecipe`.
|
|
||||||
where: { sourcePlanningItemId: null },
|
|
||||||
include: {
|
include: {
|
||||||
recipe: {
|
recipe: {
|
||||||
select: {
|
select: {
|
||||||
|
|
@ -52,7 +47,6 @@ function cookingSessionPlanningInclude() {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
utensils: { include: { utensil: true } },
|
utensils: { include: { utensil: true } },
|
||||||
temperatures: true,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -89,28 +83,10 @@ function toOptimizerRecipe(item: PlanningItemWithSteps): OptimizerRecipeInput {
|
||||||
unit: line.unit === null ? null : toUnitView(line.unit),
|
unit: line.unit === null ? null : toUnitView(line.unit),
|
||||||
}));
|
}));
|
||||||
return {
|
return {
|
||||||
techStep: {
|
techStep: { id: techStep.techStep.id, key: techStep.techStep.key },
|
||||||
id: techStep.techStep.id,
|
|
||||||
key: techStep.techStep.key,
|
|
||||||
isPlaceholder: techStep.techStep.isPlaceholder,
|
|
||||||
displayName: techStep.techStep.displayName,
|
|
||||||
},
|
|
||||||
order: techStep.order,
|
order: techStep.order,
|
||||||
ingredients,
|
ingredients,
|
||||||
utensils: techStep.utensils.map(({ utensil }) => ({ id: utensil.id, key: utensil.key })),
|
utensils: techStep.utensils.map(({ utensil }) => ({ id: utensil.id, key: utensil.key })),
|
||||||
// Structured value only — `Decimal` °C → plain number, `qualitative`
|
|
||||||
// re-narrowed to the literal union (same posture as
|
|
||||||
// `recipe.service.ts`'s `toStepTechStepViews`).
|
|
||||||
temperatures: techStep.temperatures.map((temperature) => ({
|
|
||||||
celsius: temperature.celsius === null ? null : Number(temperature.celsius),
|
|
||||||
gasMark: temperature.gasMark,
|
|
||||||
qualitative:
|
|
||||||
temperature.qualitative === "low" ||
|
|
||||||
temperature.qualitative === "medium" ||
|
|
||||||
temperature.qualitative === "high"
|
|
||||||
? temperature.qualitative
|
|
||||||
: null,
|
|
||||||
})),
|
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
|
||||||
import { Router } from "express";
|
|
||||||
import { getLatestApkUrl, getLatestVersion } from "./mobile.service.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Router mounted at `/mobile` in app.ts. Both routes are deliberately
|
|
||||||
* public (no `requireAuth`): the landing page's download link is shown to
|
|
||||||
* logged-out visitors, and the native update check (issue #66) runs on
|
|
||||||
* every app start, session or not. Both exist purely to work around
|
|
||||||
* Forgejo's Releases API sending no CORS headers — see `mobile.service.ts`'s
|
|
||||||
* own doc comment for the full story.
|
|
||||||
*/
|
|
||||||
export const mobileRouter = Router();
|
|
||||||
|
|
||||||
mobileRouter.get(
|
|
||||||
"/latest-release",
|
|
||||||
wrapAsyncHandler(async (_req, res) => {
|
|
||||||
res.status(200).json({ version: await getLatestVersion() });
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// A plain redirect, not JSON — lets any caller (the landing page's `<a>`,
|
|
||||||
// the update banner's "Mettre à jour" link) use a static href with zero
|
|
||||||
// client-side fetch, and always resolves to something useful even if
|
|
||||||
// Forgejo is briefly unreachable (see getLatestApkUrl's fallback).
|
|
||||||
mobileRouter.get(
|
|
||||||
"/download",
|
|
||||||
wrapAsyncHandler(async (_req, res) => {
|
|
||||||
res.redirect(302, await getLatestApkUrl());
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
import { HttpError } from "@batch-cooking/error-tools";
|
|
||||||
import { ErrorCode } from "@batch-cooking/shared";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Forgejo's own Releases API for this repo — public, unauthenticated (the
|
|
||||||
* same fact `local-verification-while-ci-disabled` already leans on for
|
|
||||||
* CI-status checks without a token). **Must be called server-side, not
|
|
||||||
* from the browser**: verified live that Forgejo sends no
|
|
||||||
* `Access-Control-Allow-Origin` header on it, so a direct `fetch()` from
|
|
||||||
* `apps/web` fails with an opaque CORS error even though the endpoint
|
|
||||||
* itself is perfectly reachable — this whole module exists to proxy it
|
|
||||||
* from a context CORS doesn't apply to.
|
|
||||||
*/
|
|
||||||
const LATEST_RELEASE_API_URL =
|
|
||||||
"https://git.dev.kyuno.fr/api/v1/repos/kyuno/batchCooking/releases/latest";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Human-facing Forgejo release page — safe fallback for {@link getLatestApkUrl}
|
|
||||||
* when no matching asset is found (a release with nothing attached yet,
|
|
||||||
* Forgejo briefly unreachable, ...). Never a dead link, just a slightly
|
|
||||||
* less direct one: the visitor lands on the release itself and can find
|
|
||||||
* the asset from there.
|
|
||||||
*/
|
|
||||||
const LATEST_RELEASE_PAGE_URL = "https://git.dev.kyuno.fr/kyuno/batchCooking/releases/latest";
|
|
||||||
|
|
||||||
/** Stable filename `release.yml` always attaches the signed APK under — see that workflow's own comment. */
|
|
||||||
const APK_ASSET_NAME = "batchcooking.apk";
|
|
||||||
|
|
||||||
interface ForgejoReleaseAsset {
|
|
||||||
name: string;
|
|
||||||
browser_download_url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ForgejoRelease {
|
|
||||||
tag_name: string;
|
|
||||||
assets: ForgejoReleaseAsset[];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchLatestRelease(): Promise<ForgejoRelease> {
|
|
||||||
const response = await fetch(LATEST_RELEASE_API_URL);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new HttpError(
|
|
||||||
502,
|
|
||||||
ErrorCode.INTERNAL_ERROR,
|
|
||||||
`Forgejo releases API responded ${response.status}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (await response.json()) as ForgejoRelease;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The latest tagged Android build's version (e.g. `"v1.4.2"`) — used by the
|
|
||||||
* native app's update check (issue #66, `useAppUpdateCheck.ts`).
|
|
||||||
*/
|
|
||||||
export async function getLatestVersion(): Promise<string> {
|
|
||||||
const release = await fetchLatestRelease();
|
|
||||||
return release.tag_name;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Where a "download the app" click should end up right now: the actual
|
|
||||||
* signed-APK asset of the latest release if one is attached, else
|
|
||||||
* {@link LATEST_RELEASE_PAGE_URL} as a safe fallback. Never throws — a
|
|
||||||
* download link must always go *somewhere* useful, even when Forgejo is
|
|
||||||
* briefly unreachable.
|
|
||||||
*/
|
|
||||||
export async function getLatestApkUrl(): Promise<string> {
|
|
||||||
try {
|
|
||||||
const release = await fetchLatestRelease();
|
|
||||||
const apk = release.assets.find((asset) => asset.name === APK_ASSET_NAME);
|
|
||||||
return apk?.browser_download_url ?? LATEST_RELEASE_PAGE_URL;
|
|
||||||
} catch {
|
|
||||||
return LATEST_RELEASE_PAGE_URL;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +1,10 @@
|
||||||
import { parseDateOnly } from "@batch-cooking/date-tools";
|
import { parseDateOnly } from "@batch-cooking/date-tools";
|
||||||
import { HttpError } from "@batch-cooking/error-tools";
|
import { HttpError } from "@batch-cooking/error-tools";
|
||||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||||
import {
|
import { addPlanningItemSchema, ErrorCode, getPlanningByDateSchema } from "@batch-cooking/shared";
|
||||||
addPlanningItemSchema,
|
|
||||||
assignLeftoverSchema,
|
|
||||||
ErrorCode,
|
|
||||||
getPlanningByDateSchema,
|
|
||||||
} from "@batch-cooking/shared";
|
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
||||||
import {
|
import { addPlanningItem, getPlanningForDate, removePlanningItem } from "./planning.service.js";
|
||||||
addPlanningItem,
|
|
||||||
assignLeftoverPlanningItem,
|
|
||||||
discardLeftover,
|
|
||||||
getPlanningForDate,
|
|
||||||
removePlanningItem,
|
|
||||||
} from "./planning.service.js";
|
|
||||||
|
|
||||||
/** Router mounted at `/planning` in app.ts. */
|
/** Router mounted at `/planning` in app.ts. */
|
||||||
export const planningRouter = Router();
|
export const planningRouter = Router();
|
||||||
|
|
@ -80,44 +69,6 @@ planningRouter.post(
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
|
||||||
* Serves part of another planning item's leftover pool on a slot — creates
|
|
||||||
* a "leftovers" `PlanningItem` (see {@link assignLeftoverPlanningItem}).
|
|
||||||
* `POST /items/leftover`, not `/items/:id/...`: the `:id` here would be the
|
|
||||||
* *target* slot, which doesn't exist yet — the source is in the body.
|
|
||||||
*/
|
|
||||||
planningRouter.post(
|
|
||||||
"/items/leftover",
|
|
||||||
requireAuth,
|
|
||||||
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
|
||||||
const input = assignLeftoverSchema.parse(req.body);
|
|
||||||
const date = parseDateOnly(input.date);
|
|
||||||
if (date === null) {
|
|
||||||
throw new HttpError(
|
|
||||||
400,
|
|
||||||
ErrorCode.VALIDATION_ERROR,
|
|
||||||
`Not a real calendar date: ${input.date}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { id: viewerId, houseId } = res.locals.userProfile;
|
|
||||||
const item = await assignLeftoverPlanningItem(houseId, viewerId, date, input);
|
|
||||||
res.status(201).json(item);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
/** Throws away item `:id`'s whole remaining leftover pool (idempotent — see {@link discardLeftover}). */
|
|
||||||
planningRouter.patch(
|
|
||||||
"/items/:id/discard-leftover",
|
|
||||||
requireAuth,
|
|
||||||
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
|
||||||
const id = parsePlanningItemId(req.params.id);
|
|
||||||
const { id: viewerId, houseId } = res.locals.userProfile;
|
|
||||||
const item = await discardLeftover(id, viewerId, houseId);
|
|
||||||
res.status(200).json(item);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
/** Removes one recipe from a planning slot. */
|
/** Removes one recipe from a planning slot. */
|
||||||
planningRouter.delete(
|
planningRouter.delete(
|
||||||
"/items/:id",
|
"/items/:id",
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,6 @@ import { type DateTime, getWeekStart, toDateOnly } from "@batch-cooking/date-too
|
||||||
import { HttpError } from "@batch-cooking/error-tools";
|
import { HttpError } from "@batch-cooking/error-tools";
|
||||||
import {
|
import {
|
||||||
type AddPlanningItemInput,
|
type AddPlanningItemInput,
|
||||||
type AssignLeftoverInput,
|
|
||||||
computeAvailableLeftover,
|
|
||||||
computeBatchedPortions,
|
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
type PlanningItemView,
|
type PlanningItemView,
|
||||||
type PlanningView,
|
type PlanningView,
|
||||||
|
|
@ -13,35 +10,6 @@ import { prisma } from "../../db/prisma.js";
|
||||||
import { analytics } from "../../lib/analytics.service.js";
|
import { analytics } from "../../lib/analytics.service.js";
|
||||||
import { assertRecipeVisible } from "../recipe/recipe.service.js";
|
import { assertRecipeVisible } from "../recipe/recipe.service.js";
|
||||||
|
|
||||||
/**
|
|
||||||
* One `PlanningItem` row (with its recipe resolved to `{id, name}`) shaped
|
|
||||||
* into the wire {@link PlanningItemView}. The three read/write paths below
|
|
||||||
* that return an item — `getPlanningForDate`, `addPlanningItem`,
|
|
||||||
* `assignLeftoverPlanningItem`, `discardLeftover` — all go through this so
|
|
||||||
* the shape is defined once.
|
|
||||||
*/
|
|
||||||
function toPlanningItemView(item: {
|
|
||||||
id: number;
|
|
||||||
weekDay: string;
|
|
||||||
meal: string;
|
|
||||||
portions: number;
|
|
||||||
portionsRequested: number;
|
|
||||||
discardedPortions: number;
|
|
||||||
sourcePlanningItemId: number | null;
|
|
||||||
recipe: { id: number; name: string };
|
|
||||||
}): PlanningItemView {
|
|
||||||
return {
|
|
||||||
id: item.id,
|
|
||||||
weekDay: item.weekDay,
|
|
||||||
meal: item.meal,
|
|
||||||
portions: item.portions,
|
|
||||||
portionsRequested: item.portionsRequested,
|
|
||||||
discardedPortions: item.discardedPortions,
|
|
||||||
sourceItem: item.sourcePlanningItemId !== null ? { id: item.sourcePlanningItemId } : null,
|
|
||||||
recipe: item.recipe,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finds the household's planning that covers `date` and shapes it into a
|
* Finds the household's planning that covers `date` and shapes it into a
|
||||||
* {@link PlanningView} (recipes resolved to `{id, name}`). `date` is
|
* {@link PlanningView} (recipes resolved to `{id, name}`). `date` is
|
||||||
|
|
@ -96,7 +64,13 @@ export async function getPlanningForDate(
|
||||||
id: planning.id,
|
id: planning.id,
|
||||||
startDate: planning.startDate.toISOString(),
|
startDate: planning.startDate.toISOString(),
|
||||||
finishDate: planning.finishDate.toISOString(),
|
finishDate: planning.finishDate.toISOString(),
|
||||||
items: planning.items.map(toPlanningItemView),
|
items: planning.items.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
weekDay: item.weekDay,
|
||||||
|
meal: item.meal,
|
||||||
|
portions: item.portions,
|
||||||
|
recipe: item.recipe,
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
|
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
|
||||||
|
|
@ -170,18 +144,6 @@ export async function addPlanningItem(
|
||||||
}
|
}
|
||||||
await assertRecipeVisible(input.recipeId, viewerId, viewerHouseId);
|
await assertRecipeVisible(input.recipeId, viewerId, viewerHouseId);
|
||||||
|
|
||||||
// `assertRecipeVisible` already guaranteed the recipe exists and is
|
|
||||||
// visible — this second read just pulls its written yield, needed to
|
|
||||||
// turn "portions requested" into "portions to actually prepare"
|
|
||||||
// (a whole number of batches, see `computeBatchedPortions`). The
|
|
||||||
// `?? 1` never triggers in practice (the assert above threw otherwise);
|
|
||||||
// it only keeps TypeScript happy about `findUnique`'s `| null`.
|
|
||||||
const recipe = await prisma.recipe.findUnique({
|
|
||||||
where: { id: input.recipeId },
|
|
||||||
select: { portions: true },
|
|
||||||
});
|
|
||||||
const { portions } = computeBatchedPortions(recipe?.portions ?? 1, input.portionsRequested);
|
|
||||||
|
|
||||||
const weekStart = getWeekStart(toDateOnly(date));
|
const weekStart = getWeekStart(toDateOnly(date));
|
||||||
const planning = await findOrCreatePlanningForWeek(houseId, weekStart);
|
const planning = await findOrCreatePlanningForWeek(houseId, weekStart);
|
||||||
|
|
||||||
|
|
@ -191,22 +153,23 @@ export async function addPlanningItem(
|
||||||
weekDay: input.weekDay,
|
weekDay: input.weekDay,
|
||||||
meal: input.meal,
|
meal: input.meal,
|
||||||
recipeId: input.recipeId,
|
recipeId: input.recipeId,
|
||||||
portions,
|
portions: input.portions,
|
||||||
portionsRequested: input.portionsRequested,
|
|
||||||
},
|
},
|
||||||
include: { recipe: { select: { id: true, name: true } } },
|
include: { recipe: { select: { id: true, name: true } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
analytics.recordEvent("planning.item_added", {
|
analytics.recordEvent("planning.item_added", {
|
||||||
actorId: viewerId,
|
actorId: viewerId,
|
||||||
context: {
|
context: { recipeId: input.recipeId, portions: input.portions },
|
||||||
recipeId: input.recipeId,
|
|
||||||
portions,
|
|
||||||
portionsRequested: input.portionsRequested,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return toPlanningItemView(item);
|
return {
|
||||||
|
id: item.id,
|
||||||
|
weekDay: item.weekDay,
|
||||||
|
meal: item.meal,
|
||||||
|
portions: item.portions,
|
||||||
|
recipe: item.recipe,
|
||||||
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err; // see getPlanningForDate()'s catch comment above
|
throw err; // see getPlanningForDate()'s catch comment above
|
||||||
}
|
}
|
||||||
|
|
@ -234,139 +197,3 @@ export async function removePlanningItem(id: number, houseId: number | null): Pr
|
||||||
throw err; // see getPlanningForDate()'s catch comment above
|
throw err; // see getPlanningForDate()'s catch comment above
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads a `PlanningItem` (scoped to `houseId`) with everything
|
|
||||||
* {@link computeAvailableLeftover} needs — its own portion fields plus how
|
|
||||||
* much of its pool has already been served elsewhere (`leftoverUses`) — or
|
|
||||||
* throws `404 PLANNING_ITEM_NOT_FOUND` (same "don't confirm what exists"
|
|
||||||
* posture as `removePlanningItem`). Shared by
|
|
||||||
* {@link assignLeftoverPlanningItem} and {@link discardLeftover}.
|
|
||||||
*/
|
|
||||||
async function loadItemWithLeftoverPool(id: number, houseId: number | null) {
|
|
||||||
const item = await prisma.planningItem.findUnique({
|
|
||||||
where: { id },
|
|
||||||
include: {
|
|
||||||
planning: { select: { houseId: true } },
|
|
||||||
leftoverUses: { select: { portions: true } },
|
|
||||||
recipe: { select: { id: true, name: true } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!item || houseId === null || item.planning.houseId !== houseId) {
|
|
||||||
throw new HttpError(404, ErrorCode.PLANNING_ITEM_NOT_FOUND, `Planning item ${id} not found`);
|
|
||||||
}
|
|
||||||
return item;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Serves `input.portions` of another item's leftover pool on the
|
|
||||||
* `(date, weekDay, meal)` slot — creates a "leftovers" `PlanningItem`
|
|
||||||
* (`sourcePlanningItemId` set) on the same recipe as its source. Nothing
|
|
||||||
* new is cooked for it, so `portions === portionsRequested` and it excludes
|
|
||||||
* itself from the shopping list / cooking session (see those services'
|
|
||||||
* `where: { sourcePlanningItemId: null }`).
|
|
||||||
*
|
|
||||||
* The source's `recipeId` is copied, never taken from the client (a
|
|
||||||
* leftovers item can only be its source's recipe). `input.portions` is
|
|
||||||
* re-checked against the pool actually still available
|
|
||||||
* ({@link computeAvailableLeftover}) — trusting a client value here would
|
|
||||||
* let a household conjure portions it never cooked.
|
|
||||||
*
|
|
||||||
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if `houseId` is `null`.
|
|
||||||
* @throws {HttpError} `404 PLANNING_ITEM_NOT_FOUND` if `sourceItemId` doesn't exist, belongs to another household, or is itself a leftovers item (no leftovers-on-leftovers).
|
|
||||||
* @throws {HttpError} `409 LEFTOVER_QUANTITY_EXCEEDED` if `portions` exceeds the source's still-available pool.
|
|
||||||
*/
|
|
||||||
export async function assignLeftoverPlanningItem(
|
|
||||||
houseId: number | null,
|
|
||||||
viewerId: number,
|
|
||||||
date: DateTime,
|
|
||||||
input: AssignLeftoverInput,
|
|
||||||
): Promise<PlanningItemView> {
|
|
||||||
try {
|
|
||||||
if (houseId === null) {
|
|
||||||
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
|
||||||
}
|
|
||||||
|
|
||||||
const source = await loadItemWithLeftoverPool(input.sourceItemId, houseId);
|
|
||||||
if (source.sourcePlanningItemId !== null) {
|
|
||||||
// A leftovers item has no pool of its own — serving leftovers cooks
|
|
||||||
// nothing, so it can't be a source for more.
|
|
||||||
throw new HttpError(
|
|
||||||
404,
|
|
||||||
ErrorCode.PLANNING_ITEM_NOT_FOUND,
|
|
||||||
`Planning item ${input.sourceItemId} is not a leftovers source`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const available = computeAvailableLeftover(source);
|
|
||||||
if (input.portions > available) {
|
|
||||||
throw new HttpError(
|
|
||||||
409,
|
|
||||||
ErrorCode.LEFTOVER_QUANTITY_EXCEEDED,
|
|
||||||
`Item ${source.id} has ${available} leftover portion(s) available, asked for ${input.portions}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const weekStart = getWeekStart(toDateOnly(date));
|
|
||||||
const planning = await findOrCreatePlanningForWeek(houseId, weekStart);
|
|
||||||
|
|
||||||
const item = await prisma.planningItem.create({
|
|
||||||
data: {
|
|
||||||
planningId: planning.id,
|
|
||||||
weekDay: input.weekDay,
|
|
||||||
meal: input.meal,
|
|
||||||
recipeId: source.recipeId,
|
|
||||||
portions: input.portions,
|
|
||||||
portionsRequested: input.portions,
|
|
||||||
sourcePlanningItemId: source.id,
|
|
||||||
},
|
|
||||||
include: { recipe: { select: { id: true, name: true } } },
|
|
||||||
});
|
|
||||||
|
|
||||||
analytics.recordEvent("planning.leftover_assigned", {
|
|
||||||
actorId: viewerId,
|
|
||||||
context: { recipeId: source.recipeId, portions: input.portions, sourceItemId: source.id },
|
|
||||||
});
|
|
||||||
|
|
||||||
return toPlanningItemView(item);
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see getPlanningForDate()'s catch comment above
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Throws away the whole still-available leftover pool of item `id`
|
|
||||||
* (`discardedPortions += available`) — the basket's "jeter" button.
|
|
||||||
* Idempotent: an item with nothing left to discard is returned unchanged,
|
|
||||||
* no event recorded.
|
|
||||||
*
|
|
||||||
* @throws {HttpError} `404 PLANNING_ITEM_NOT_FOUND` — same guard as `removePlanningItem`.
|
|
||||||
*/
|
|
||||||
export async function discardLeftover(
|
|
||||||
id: number,
|
|
||||||
viewerId: number,
|
|
||||||
houseId: number | null,
|
|
||||||
): Promise<PlanningItemView> {
|
|
||||||
try {
|
|
||||||
const item = await loadItemWithLeftoverPool(id, houseId);
|
|
||||||
const available = computeAvailableLeftover(item);
|
|
||||||
if (available === 0) {
|
|
||||||
return toPlanningItemView(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await prisma.planningItem.update({
|
|
||||||
where: { id },
|
|
||||||
data: { discardedPortions: item.discardedPortions + available },
|
|
||||||
include: { recipe: { select: { id: true, name: true } } },
|
|
||||||
});
|
|
||||||
|
|
||||||
analytics.recordEvent("planning.leftover_discarded", {
|
|
||||||
actorId: viewerId,
|
|
||||||
context: { recipeId: item.recipe.id, portions: available },
|
|
||||||
});
|
|
||||||
|
|
||||||
return toPlanningItemView(updated);
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see getPlanningForDate()'s catch comment above
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue