From 32fccb3f47412914bd68ef2896d6aa84b4a302ce Mon Sep 17 00:00:00 2001 From: Tamas Vince Date: Wed, 30 Sep 2026 23:20:46 +0200 Subject: [PATCH 01/32] feat: replace Hono + hono-openapi with a typed oRPC contract Chronos now serves a single oRPC contract owned by @filcdev/api instead of Hono routes with per-request zod validation: - packages/api holds the contract (one procedure per endpoint, with the filcRoute metadata mergen's generated Kotlin client reads), the wire schemas, the shared error map and the typed client factory. - Chronos binds handlers to that contract with implement(appContract), so a missing, extra or mistyped procedure fails tsc instead of 404ing. - iris and kiosk consume the contract through createApiClient; iris also moves onto TanStack Start SSR with a per-request client and router. - The response envelope is gone: payloads are the value, and errors carry the machine-readable code from the shared error map. - better-auth, the aegis door-lock WebSocket and the unsubscribe HTML pages stay outside the contract and are dispatched from src/index.ts. Gates: bun lint, bun typecheck (8/8), bun run build (3/3). --- AGENTS.md | 43 +- CONTRIBUTING.md | 2 +- README.md | 4 +- apps/chronos/openapi/chronos-openapi.json | 44593 ++++++++++++++++ apps/chronos/package.json | 19 +- apps/chronos/scripts/generate-openapi.ts | 34 + apps/chronos/src/_types/auth.ts | 11 +- apps/chronos/src/_types/globals.ts | 38 +- apps/chronos/src/_types/hc.ts | 25 - apps/chronos/src/index.ts | 362 +- apps/chronos/src/middleware/anonymous-id.ts | 65 - apps/chronos/src/middleware/auth.ts | 176 +- apps/chronos/src/middleware/rate-limit.ts | 36 + apps/chronos/src/middleware/security.ts | 48 - apps/chronos/src/middleware/timing.ts | 45 - apps/chronos/src/orpc.ts | 11 + apps/chronos/src/router.ts | 39 + .../chronos/src/routes/bug-report/_factory.ts | 4 - apps/chronos/src/routes/bug-report/_router.ts | 13 +- apps/chronos/src/routes/bug-report/index.ts | 202 +- apps/chronos/src/routes/cohort/_factory.ts | 4 - apps/chronos/src/routes/cohort/_router.ts | 3 +- apps/chronos/src/routes/cohort/index.ts | 37 +- apps/chronos/src/routes/dashboard/_factory.ts | 4 - apps/chronos/src/routes/dashboard/_router.ts | 5 +- apps/chronos/src/routes/dashboard/index.ts | 36 +- apps/chronos/src/routes/doorlock/_factory.ts | 10 - apps/chronos/src/routes/doorlock/_router.ts | 95 +- apps/chronos/src/routes/doorlock/cards.ts | 229 +- ...{websocket-handler.ts => device-socket.ts} | 234 +- apps/chronos/src/routes/doorlock/devices.ts | 203 +- apps/chronos/src/routes/doorlock/export.ts | 46 +- apps/chronos/src/routes/doorlock/logs.ts | 78 +- apps/chronos/src/routes/doorlock/ota.ts | 72 +- apps/chronos/src/routes/doorlock/self.ts | 215 +- apps/chronos/src/routes/doorlock/stats.ts | 82 +- apps/chronos/src/routes/health/_factory.ts | 4 - apps/chronos/src/routes/health/_router.ts | 3 +- apps/chronos/src/routes/health/index.ts | 45 +- apps/chronos/src/routes/kiosk/_factory.ts | 4 - apps/chronos/src/routes/kiosk/_router.ts | 46 +- apps/chronos/src/routes/kiosk/departures.ts | 86 +- apps/chronos/src/routes/kiosk/heartbeat.ts | 75 +- apps/chronos/src/routes/kiosk/index.ts | 181 +- apps/chronos/src/routes/kiosk/news-image.ts | 48 +- apps/chronos/src/routes/kiosk/news.ts | 132 +- apps/chronos/src/routes/kiosk/petrik-news.ts | 144 +- apps/chronos/src/routes/kiosk/weather.ts | 101 +- apps/chronos/src/routes/navigator/_factory.ts | 4 - apps/chronos/src/routes/navigator/_router.ts | 93 +- .../chronos/src/routes/navigator/buildings.ts | 166 +- .../src/routes/navigator/classroom-types.ts | 181 +- .../src/routes/navigator/classrooms.ts | 169 +- .../chronos/src/routes/navigator/corridors.ts | 164 +- apps/chronos/src/routes/navigator/graph.ts | 124 +- apps/chronos/src/routes/navigator/lifts.ts | 172 +- apps/chronos/src/routes/navigator/stairs.ts | 172 +- apps/chronos/src/routes/navigator/transfer.ts | 101 +- .../src/routes/navigator/translations.ts | 227 +- apps/chronos/src/routes/news/_factory.ts | 4 - apps/chronos/src/routes/news/_router.ts | 65 +- apps/chronos/src/routes/news/announcements.ts | 348 +- apps/chronos/src/routes/news/blogs.ts | 408 +- .../src/routes/news/system-messages.ts | 234 +- .../src/routes/notifications/_factory.ts | 4 - .../src/routes/notifications/_router.ts | 37 +- .../src/routes/notifications/notifications.ts | 347 +- .../routes/notifications/unsubscribe-html.ts | 138 + apps/chronos/src/routes/ping/_factory.ts | 4 - apps/chronos/src/routes/ping/_router.ts | 6 +- apps/chronos/src/routes/ping/index.ts | 27 +- apps/chronos/src/routes/ping/uptime.ts | 39 +- apps/chronos/src/routes/roles/_factory.ts | 4 - apps/chronos/src/routes/roles/_router.ts | 15 +- apps/chronos/src/routes/roles/index.ts | 158 +- .../routes/timetable/_curriculum-router.ts | 64 + apps/chronos/src/routes/timetable/_factory.ts | 4 - apps/chronos/src/routes/timetable/_router.ts | 151 +- apps/chronos/src/routes/timetable/cohort.ts | 52 +- apps/chronos/src/routes/timetable/export.ts | 81 +- apps/chronos/src/routes/timetable/groups.ts | 117 +- apps/chronos/src/routes/timetable/import.ts | 76 +- apps/chronos/src/routes/timetable/index.ts | 287 +- apps/chronos/src/routes/timetable/lesson.ts | 644 +- .../src/routes/timetable/moved-lesson.ts | 351 +- apps/chronos/src/routes/timetable/period.ts | 50 +- apps/chronos/src/routes/timetable/room.ts | 113 +- .../src/routes/timetable/substitution.ts | 477 +- apps/chronos/src/routes/timetable/teacher.ts | 187 +- apps/chronos/src/routes/users/_factory.ts | 4 - apps/chronos/src/routes/users/_router.ts | 20 +- apps/chronos/src/routes/users/api-keys.ts | 188 +- apps/chronos/src/routes/users/index.ts | 125 +- apps/chronos/src/utils/authentication.ts | 26 +- .../chronos/src/utils/doorlock/device-sync.ts | 2 +- apps/chronos/src/utils/http.ts | 80 +- apps/chronos/src/utils/kiosk/feed-url.ts | 8 +- apps/chronos/src/utils/kiosk/schemas.ts | 97 - apps/chronos/src/utils/news/cohort.ts | 7 +- apps/chronos/src/utils/news/kiosks.ts | 7 +- apps/chronos/src/utils/openapi-spec.ts | 49 + apps/chronos/src/utils/openapi.ts | 8 - apps/chronos/src/utils/telemetry.ts | 4 +- apps/iris/Dockerfile | 15 +- apps/iris/index.html | 36 - apps/iris/nginx.conf | 33 - apps/iris/package.json | 11 +- apps/iris/public/healthz | 0 apps/iris/server.ts | 31 + apps/iris/src/client.tsx | 26 + apps/iris/src/components/admin/admin.types.ts | 3 +- .../components/admin/announcements-dialog.tsx | 14 +- apps/iris/src/components/admin/dashboard.tsx | 17 +- .../src/components/admin/kiosk-dialog.tsx | 19 +- .../components/admin/moved-lesson-dialog.tsx | 96 +- .../components/admin/moved-lesson-export.tsx | 19 +- .../admin/navigator/navigator-preview.tsx | 9 +- .../admin/navigator/transfer-actions.tsx | 8 +- .../src/components/admin/news-item-dialog.tsx | 4 +- .../iris/src/components/admin/roles-table.tsx | 2 +- apps/iris/src/components/admin/sidebar.tsx | 4 +- .../components/admin/substitution-dialog.tsx | 113 +- .../components/admin/substitution-export.tsx | 19 +- .../admin/system-messages-dialog.tsx | 2 +- .../admin/timetable-edit-dialog.tsx | 12 +- .../iris/src/components/admin/users-table.tsx | 7 +- .../iris/src/components/bug-report-dialog.tsx | 17 +- .../doorlock/device-stats-charts.tsx | 89 + .../doorlock/device-stats-dialog.tsx | 95 +- .../src/components/doorlock/doorlock.types.ts | 3 +- .../src/components/doorlock/export-logs.tsx | 21 +- .../components/doorlock/user-card-list.tsx | 11 +- apps/iris/src/components/lazy.tsx | 33 + apps/iris/src/components/navbar.tsx | 27 +- apps/iris/src/components/news-panel.tsx | 8 +- .../iris/src/components/notification-bell.tsx | 4 +- .../notification-history-dialog.tsx | 4 +- .../components/notification-viewer-dialog.tsx | 4 +- apps/iris/src/components/settings-dialog.tsx | 16 +- apps/iris/src/components/subs-view.tsx | 105 +- .../src/components/system-message-banner.tsx | 28 +- apps/iris/src/components/timetable/index.tsx | 48 +- apps/iris/src/components/timetable/types.ts | 53 +- .../src/components/util/permission-guard.tsx | 4 +- apps/iris/src/hooks/admin-users.ts | 216 +- apps/iris/src/hooks/bug-reports.ts | 85 +- apps/iris/src/hooks/dev-notifications.ts | 83 +- apps/iris/src/hooks/doorlock-admin.ts | 413 +- apps/iris/src/hooks/kiosks.ts | 146 +- apps/iris/src/hooks/moved-lessons.ts | 199 +- apps/iris/src/hooks/navigator.ts | 934 +- apps/iris/src/hooks/news.ts | 379 +- apps/iris/src/hooks/notifications.ts | 182 +- apps/iris/src/hooks/substitutions.ts | 184 +- apps/iris/src/hooks/timetable-groups.ts | 66 +- apps/iris/src/hooks/timetable-public.ts | 227 +- apps/iris/src/hooks/timetable-teachers.ts | 123 +- apps/iris/src/hooks/timetables-admin.ts | 227 +- apps/iris/src/main.tsx | 93 - .../{route-tree.gen.ts => routeTree.gen.ts} | 9 + apps/iris/src/router.tsx | 65 + apps/iris/src/routes/__root.tsx | 103 +- .../src/routes/_private/admin/bug-reports.tsx | 14 +- .../routes/_private/admin/doorlock/cards.tsx | 12 +- .../_private/admin/doorlock/devices.tsx | 9 +- .../routes/_private/admin/doorlock/index.tsx | 3 + .../routes/_private/admin/doorlock/logs.tsx | 21 +- .../iris/src/routes/_private/admin/kiosks.tsx | 5 +- .../_private/admin/navigator/buildings.tsx | 24 +- .../admin/navigator/classroom-types.tsx | 13 +- .../_private/admin/navigator/classrooms.tsx | 28 +- .../_private/admin/navigator/corridors.tsx | 28 +- .../routes/_private/admin/navigator/index.tsx | 3 + .../routes/_private/admin/navigator/lifts.tsx | 23 +- .../_private/admin/navigator/preview.tsx | 3 + .../_private/admin/navigator/stairs.tsx | 23 +- .../_private/admin/navigator/translations.tsx | 14 +- .../_private/admin/news/announcements.tsx | 21 +- .../_private/admin/news/system-messages.tsx | 15 +- apps/iris/src/routes/_private/admin/roles.tsx | 6 + apps/iris/src/routes/_private/admin/route.tsx | 20 +- .../_private/admin/timetable/manage.tsx | 16 +- .../admin/timetable/moved-lessons.tsx | 27 +- .../admin/timetable/substitutions.tsx | 22 +- .../_private/admin/timetable/teachers.tsx | 3 + apps/iris/src/routes/_private/admin/users.tsx | 21 + apps/iris/src/routes/_private/cards/index.tsx | 13 +- apps/iris/src/routes/_private/route.tsx | 8 +- apps/iris/src/routes/_public/index.tsx | 37 + apps/iris/src/routes/_public/subs.tsx | 41 +- apps/iris/src/routes/auth/login.tsx | 2 +- apps/iris/src/routes/auth/welcome.tsx | 6 +- apps/iris/src/utils/api.ts | 56 - apps/iris/src/utils/authentication.ts | 13 - apps/iris/src/utils/date-locale.ts | 16 +- apps/iris/src/utils/hc.ts | 42 - apps/iris/src/utils/i18n.ts | 54 +- apps/iris/src/utils/orpc.ts | 58 + apps/iris/src/utils/query-keys.ts | 121 - apps/iris/src/utils/request.ts | 34 + apps/iris/src/utils/telemetry.ts | 5 +- apps/iris/tsconfig.json | 6 +- apps/iris/vite.config.ts | 8 +- apps/kiosk/package.json | 2 - .../kiosk/src/components/tv/news-takeover.tsx | 2 +- apps/kiosk/src/hooks/kiosk.ts | 21 +- apps/kiosk/src/hooks/petrik-news.ts | 20 +- apps/kiosk/src/hooks/tv.ts | 114 +- .../kiosk/src/hooks/use-kiosk-translations.ts | 21 +- apps/kiosk/src/routes/_kiosk/navigator.tsx | 8 +- apps/kiosk/src/utils/api.ts | 94 - apps/kiosk/src/utils/orpc.ts | 23 + biome.jsonc | 3 +- bun.lock | 220 +- docs/architecture.md | 37 +- package.json | 10 +- packages/api/package.json | 6 +- packages/api/src/client/index.ts | 126 +- packages/api/src/contract/bug-report.ts | 78 + packages/api/src/contract/cohort.ts | 21 + packages/api/src/contract/dashboard.ts | 24 + packages/api/src/contract/doorlock.ts | 329 + packages/api/src/contract/health.ts | 21 + packages/api/src/contract/index.ts | 42 + packages/api/src/contract/kiosk.ts | 190 + packages/api/src/contract/navigator.ts | 595 + packages/api/src/contract/news.ts | 385 + packages/api/src/contract/notifications.ts | 54 + packages/api/src/contract/ping.ts | 34 + packages/api/src/contract/roles.ts | 77 + packages/api/src/contract/route.ts | 52 + packages/api/src/contract/timetable/import.ts | 23 + packages/api/src/contract/timetable/index.ts | 20 + .../api/src/contract/timetable/lessons.ts | 118 + .../src/contract/timetable/moved-lessons.ts | 143 + packages/api/src/contract/timetable/people.ts | 211 + .../src/contract/timetable/substitutions.ts | 152 + .../api/src/contract/timetable/timetables.ts | 128 + packages/api/src/contract/users.ts | 104 + packages/api/src/domains/api-keys.ts | 25 + packages/api/src/domains/bug-report.ts | 57 + packages/api/src/domains/cohort.ts | 13 + packages/api/src/domains/dashboard.ts | 23 +- packages/api/src/domains/doorlock/cards.ts | 45 + packages/api/src/domains/doorlock/devices.ts | 26 + packages/api/src/domains/doorlock/logs.ts | 25 + packages/api/src/domains/doorlock/self.ts | 6 + packages/api/src/domains/doorlock/stats.ts | 106 +- packages/api/src/domains/health.ts | 9 +- packages/api/src/domains/kiosk/config.ts | 9 + packages/api/src/domains/kiosk/heartbeat.ts | 22 + packages/api/src/domains/kiosk/responses.ts | 95 + .../api/src/domains/navigator/building.ts | 26 + .../src/domains/navigator/classroom-type.ts | 21 + .../api/src/domains/navigator/classroom.ts | 34 + .../api/src/domains/navigator/corridor.ts | 29 + packages/api/src/domains/navigator/lift.ts | 25 + packages/api/src/domains/navigator/stair.ts | 26 + .../api/src/domains/navigator/transfer.ts | 25 + .../api/src/domains/navigator/translation.ts | 15 + .../api/src/domains/news/announcements.ts | 45 + packages/api/src/domains/news/blogs.ts | 26 +- .../api/src/domains/news/system-messages.ts | 30 +- packages/api/src/domains/notifications.ts | 84 + packages/api/src/domains/ping.ts | 16 +- packages/api/src/domains/roles.ts | 21 + packages/api/src/domains/timetable/groups.ts | 17 +- packages/api/src/domains/timetable/import.ts | 6 +- packages/api/src/domains/timetable/lesson.ts | 108 + .../api/src/domains/timetable/moved-lesson.ts | 60 + packages/api/src/domains/timetable/period.ts | 18 +- packages/api/src/domains/timetable/room.ts | 24 + packages/api/src/domains/timetable/subject.ts | 10 + .../api/src/domains/timetable/substitution.ts | 60 + packages/api/src/domains/timetable/teacher.ts | 14 - .../api/src/domains/timetable/timetables.ts | 79 +- packages/api/src/domains/users.ts | 33 + packages/api/src/envelope.ts | 41 - packages/api/src/errors.ts | 80 +- packages/api/src/permissions.ts | 7 + packages/auth/package.json | 25 + packages/auth/src/client.ts | 66 + packages/auth/src/index.ts | 82 + packages/auth/tsconfig.json | 19 + 284 files changed, 54424 insertions(+), 11097 deletions(-) create mode 100644 apps/chronos/openapi/chronos-openapi.json create mode 100644 apps/chronos/scripts/generate-openapi.ts delete mode 100644 apps/chronos/src/_types/hc.ts delete mode 100644 apps/chronos/src/middleware/anonymous-id.ts create mode 100644 apps/chronos/src/middleware/rate-limit.ts delete mode 100644 apps/chronos/src/middleware/security.ts delete mode 100644 apps/chronos/src/middleware/timing.ts create mode 100644 apps/chronos/src/orpc.ts create mode 100644 apps/chronos/src/router.ts delete mode 100644 apps/chronos/src/routes/bug-report/_factory.ts delete mode 100644 apps/chronos/src/routes/cohort/_factory.ts delete mode 100644 apps/chronos/src/routes/dashboard/_factory.ts delete mode 100644 apps/chronos/src/routes/doorlock/_factory.ts rename apps/chronos/src/routes/doorlock/{websocket-handler.ts => device-socket.ts} (58%) delete mode 100644 apps/chronos/src/routes/health/_factory.ts delete mode 100644 apps/chronos/src/routes/kiosk/_factory.ts delete mode 100644 apps/chronos/src/routes/navigator/_factory.ts delete mode 100644 apps/chronos/src/routes/news/_factory.ts delete mode 100644 apps/chronos/src/routes/notifications/_factory.ts create mode 100644 apps/chronos/src/routes/notifications/unsubscribe-html.ts delete mode 100644 apps/chronos/src/routes/ping/_factory.ts delete mode 100644 apps/chronos/src/routes/roles/_factory.ts create mode 100644 apps/chronos/src/routes/timetable/_curriculum-router.ts delete mode 100644 apps/chronos/src/routes/timetable/_factory.ts delete mode 100644 apps/chronos/src/routes/users/_factory.ts delete mode 100644 apps/chronos/src/utils/kiosk/schemas.ts create mode 100644 apps/chronos/src/utils/openapi-spec.ts delete mode 100644 apps/chronos/src/utils/openapi.ts delete mode 100644 apps/iris/index.html delete mode 100644 apps/iris/nginx.conf create mode 100644 apps/iris/public/healthz create mode 100644 apps/iris/server.ts create mode 100644 apps/iris/src/client.tsx create mode 100644 apps/iris/src/components/doorlock/device-stats-charts.tsx create mode 100644 apps/iris/src/components/lazy.tsx delete mode 100644 apps/iris/src/main.tsx rename apps/iris/src/{route-tree.gen.ts => routeTree.gen.ts} (99%) create mode 100644 apps/iris/src/router.tsx delete mode 100644 apps/iris/src/utils/api.ts delete mode 100644 apps/iris/src/utils/authentication.ts delete mode 100644 apps/iris/src/utils/hc.ts create mode 100644 apps/iris/src/utils/orpc.ts delete mode 100644 apps/iris/src/utils/query-keys.ts create mode 100644 apps/iris/src/utils/request.ts delete mode 100644 apps/kiosk/src/utils/api.ts create mode 100644 apps/kiosk/src/utils/orpc.ts create mode 100644 packages/api/src/contract/bug-report.ts create mode 100644 packages/api/src/contract/cohort.ts create mode 100644 packages/api/src/contract/dashboard.ts create mode 100644 packages/api/src/contract/doorlock.ts create mode 100644 packages/api/src/contract/health.ts create mode 100644 packages/api/src/contract/index.ts create mode 100644 packages/api/src/contract/kiosk.ts create mode 100644 packages/api/src/contract/navigator.ts create mode 100644 packages/api/src/contract/news.ts create mode 100644 packages/api/src/contract/notifications.ts create mode 100644 packages/api/src/contract/ping.ts create mode 100644 packages/api/src/contract/roles.ts create mode 100644 packages/api/src/contract/route.ts create mode 100644 packages/api/src/contract/timetable/import.ts create mode 100644 packages/api/src/contract/timetable/index.ts create mode 100644 packages/api/src/contract/timetable/lessons.ts create mode 100644 packages/api/src/contract/timetable/moved-lessons.ts create mode 100644 packages/api/src/contract/timetable/people.ts create mode 100644 packages/api/src/contract/timetable/substitutions.ts create mode 100644 packages/api/src/contract/timetable/timetables.ts create mode 100644 packages/api/src/contract/users.ts create mode 100644 packages/api/src/domains/cohort.ts create mode 100644 packages/api/src/domains/kiosk/responses.ts create mode 100644 packages/api/src/domains/navigator/transfer.ts create mode 100644 packages/api/src/domains/timetable/subject.ts delete mode 100644 packages/api/src/envelope.ts create mode 100644 packages/auth/package.json create mode 100644 packages/auth/src/client.ts create mode 100644 packages/auth/src/index.ts create mode 100644 packages/auth/tsconfig.json diff --git a/AGENTS.md b/AGENTS.md index 77abdf24..ee2586fa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,8 +3,9 @@ ## Repo Shape - This is a Bun + Turborepo monorepo. Shared scripts live in [package.json](package.json), task wiring lives in [turbo.json](turbo.json), and lint rules live in [biome.jsonc](biome.jsonc). -- [apps/chronos](apps/chronos) is the Hono + Drizzle backend. -- [packages/api](packages/api) is the shared API contract package (`@filcdev/api`): zod wire schemas per domain, envelope/error types, permission constants, and a typed client factory consumed by Chronos and Iris alike. Future apps calling the Chronos API should depend on this package instead of the Chronos workspace. +- [apps/chronos](apps/chronos) is the oRPC + Drizzle backend: it implements the contract from `packages/api` and serves an RPC endpoint for the apps plus an OpenAPI surface for external clients. +- [packages/api](packages/api) is the shared API contract package (`@filcdev/api`): the oRPC contract, the zod wire schemas per domain, the error map, permission constants and the typed client factory. Chronos implements the contract, iris and kiosk consume it, and it is the only place endpoint metadata lives. Future apps calling the Chronos API should depend on this package instead of the Chronos workspace. +- [packages/auth](packages/auth) is the shared auth surface (`@filcdev/auth`): the better-auth user fields, the session/user types and the browser client (`authClient`, `useSession`). Chronos passes the fields to better-auth and the apps read the client; `apps/chronos/src/_types/auth.ts` proves at compile time that the two still agree. - [packages/ui](packages/ui) is the shared design system (`@filcdev/ui`): the shadcn-style primitives, `cn`, `useIsMobile` and the theme tokens in `globals.css`. Iris imports them as `@filcdev/ui/components/*`, `@filcdev/ui/lib/utils`, `@filcdev/ui/hooks/*` and `@import "@filcdev/ui/globals.css"` (plus an `@source` for the package). App-agnostic primitives belong here; app-specific composition stays in the app. - [apps/iris](apps/iris) is the React + Vite frontend. @@ -19,29 +20,33 @@ ## Conventions - Use the correct path alias for the app you are editing: `#...` in Chronos and `@/...` in Iris. -- Do not hand-edit generated files such as [apps/iris/src/route-tree.gen.ts](apps/iris/src/route-tree.gen.ts) or SQL files under [apps/chronos/src/database/migrations](apps/chronos/src/database/migrations). +- An app never imports another app. Shared code lives in a package (`@filcdev/api`, `@filcdev/auth`, `@filcdev/ui`, …) or stays in the app that needs it. +- Do not hand-edit generated files such as [apps/iris/src/routeTree.gen.ts](apps/iris/src/routeTree.gen.ts), the SQL files under [apps/chronos/src/database/migrations](apps/chronos/src/database/migrations), or the OpenAPI documents under [apps/chronos/openapi](apps/chronos/openapi) (regenerate them with `bun run openapi:generate`). - Backend commands that need auth or database configuration rely on [apps/chronos/.env.example](apps/chronos/.env.example). - If a change crosses backend and frontend, keep the API contract and client usage aligned before finishing. - Bun 1.4.0 ignores `[run] bun = true` from bunfig.toml when spawning package binaries. Scripts that need the Bun runtime (vite, drizzle-kit) must invoke them via `bun --bun `; see the `dev`, `build`, and `db:*` scripts. -- Every API error response carries a machine-readable `code` (`@filcdev/api/errors`): derived from the HTTP status unless a handler opts out via an explicit override. Throw through the helpers in Chronos' `#utils/http` (or `ApiHttpError` with `{ code }`) when the status default is ambiguous; clients receive it typed as `ErrorCode` on `ApiError`. +- Every API error response carries a machine-readable `code` from the shared error map in [packages/api/src/errors.ts](packages/api/src/errors.ts), installed on the whole contract with `oc.errors(apiErrors)`. The map is what gives a code its HTTP status, its default message and its documented error response, so a new code is added there and nowhere else. Throw through the helpers in Chronos' `#utils/http` (`notFound`, `conflict`, `badRequest`, `forbidden`, `unauthorized`, `badGateway`, `serviceUnavailable`) or the `errors.(...)` factories a handler receives; clients read the code off the thrown `ORPCError`. ## Shared API Contracts (`@filcdev/api`) -- Wire schemas describing request bodies, path/query params, and hand-written response payloads belong in `packages/api/src/domains/...`. Chronos routes import them from `@filcdev/api/domains/`; do not re-declare them locally. -- Schemas derived from Drizzle tables (`createSelectSchema(table)` and composites embedding those) stay in Chronos; the package must not import drizzle-orm or server code. +- The contract is the single source of truth: `packages/api/src/contract/.ts` declares one procedure per endpoint as `oc.route(filcRoute({...})).input(...).output(...)`, and [packages/api/src/contract/index.ts](packages/api/src/contract/index.ts) mounts them all in `appContract`. Operation metadata (`method`, `path`, `operationId`, `tags`, `successStatus`, `x-filc_*`) comes from `filcRoute` and is what the generated OpenAPI document — and mergen's Kotlin client — reads, so it is not free to change. +- `.output(...)` is mandatory on every procedure: it types the apps and produces the external clients' models. `.input(...)` is ONE flat object — path params plus query (GET) or JSON body (POST/PUT/PATCH), same field names as the wire, no nesting. A file response is `z.file()`, a multipart upload takes the file as a `z.file()` field. +- Response payloads are the unwrapped value; there is no `{ success, data }` envelope. Drizzle `timestamp` columns are `z.date()` (the RPC transport round-trips `Date` objects and the OpenAPI serializer still emits `format: date-time`), `date()` columns are `z.string()`. Deletes answer `{ id }` — or `{ ok: true }` when there is no id — with status 200; nothing answers 204. +- Wire schemas (request bodies, path/query params, hand-written response payloads, entity rows) belong in `packages/api/src/domains/...`; apps and Chronos import them from `@filcdev/api/domains/`. Entity schemas are hand-written there — `packages/api` must not import drizzle-orm or anything from `apps/chronos`, so Drizzle-derived schemas never cross the boundary. - Permission strings are canonical constants from `@filcdev/api/permissions`. Never inline `'resource:action'` literals in middleware or UI gating; they are also stored in the `roles` table, so renaming requires a data migration. -- Frontend API calls go through the typed client built in [apps/iris/src/utils/hc.ts](apps/iris/src/utils/hc.ts) (`createApiClient`) and unwrap envelopes via `unwrapResponse` (used by `useApiQuery`/`useApiMutation` in [apps/iris/src/utils/api.ts](apps/iris/src/utils/api.ts)), which throws a structured `ApiError` with `code`/`status`. +- Frontend API calls go through the client built in `apps//src/utils/orpc.ts` from `createApiClient` (`@filcdev/api/client`), which returns `{ client, orpc }`: `client` for imperative calls, `orpc` for the TanStack Query utilities (`queryOptions`, `mutationOptions`, `key`). Errors arrive as an `ORPCError` — branch on `isDefinedError(error) && error.code === '…'` or on `error.status`. +- After any contract change, regenerate the OpenAPI document: `bun run openapi:generate` in [apps/chronos](apps/chronos) rewrites `openapi/chronos-openapi.json`, which is exactly the document `/api/doc/openapi.json` serves — both come from the options in [apps/chronos/src/utils/openapi-spec.ts](apps/chronos/src/utils/openapi-spec.ts). External clients consume that file as generated (mergen copies it to its own `openapi/filc-openapi.json`); nothing post-processes a per-consumer variant. ## Chronos Backend ### Routes -- Follow the feature route layout in [apps/chronos/src/routes](apps/chronos/src/routes): keep `_factory.ts`, `_router.ts`, and handler files together inside the feature folder. -- New handlers match patterns like [apps/chronos/src/routes/ping/index.ts](apps/chronos/src/routes/ping/index.ts): `factory.createHandlers(...)` with `describeRoute(...)` then shared middleware, then validators, then the final handler, plus OpenAPI metadata via `describeRoute(...)` and `filcExt(...)`. -- Register endpoints from the feature router, following [apps/chronos/src/routes/doorlock/_router.ts](apps/chronos/src/routes/doorlock/_router.ts), so new handlers are actually reachable. -- Reuse `requireAuthentication` and `requireAuthorization(permissions.)` middleware instead of inline permission checks. -- Return the established success envelope (`ok()` helpers from `#utils/http`) and use `HTTPException` plus `StatusCodes` for error paths. -- Keep OpenAPI metadata complete enough for the endpoint to stay discoverable: tags, descriptions, and response documentation ship with the route change. +- A route is a procedure implementation: `base...handler(...)`, where `base` is `implement(appContract)` from [apps/chronos/src/orpc.ts](apps/chronos/src/orpc.ts). Because it is bound to the contract, a missing, extra or mistyped procedure fails `tsc` rather than 404ing at runtime. +- Follow the feature layout in [apps/chronos/src/routes](apps/chronos/src/routes): handler files plus a `_router.ts` that exports a PLAIN object literal mirroring the contract nesting (`export const doorlockRouter = { devices: { list, create, … } }`). `base.router({...})` requires the full contract, so it belongs in [apps/chronos/src/router.ts](apps/chronos/src/router.ts) and never in a feature file. +- Handler shape: `handler(async ({ input, context }) => payload)`; read `input` (the flat object), `context.user`/`context.session` (non-null after a guard), `context.reqHeaders.get(...)`, `context.clientIp`, and add response headers with `context.resHeaders?.set(...)`. +- Reuse `requireAuthentication` and `requireAuthorization(permissions.)` from [apps/chronos/src/middleware/auth.ts](apps/chronos/src/middleware/auth.ts) instead of inline permission checks. +- Success is `return payload` (the status comes from the route's `successStatus`); errors are thrown `ORPCError`s — the `#utils/http` helpers, or the `errors.(...)` factories a handler/middleware receives. +- The transport lives in [apps/chronos/src/index.ts](apps/chronos/src/index.ts): `/api/rpc/*` answers the typed client, `/api/*` serves OpenAPI (`/api/doc/openapi.json`) and Swagger UI (`/api/doc/swagger`). Three surfaces are deliberately outside the contract and dispatched before the handlers: `GET /api/auth/*` (better-auth), the `/api/notifications/unsubscribe` HTML pages, and the aegis door-lock WebSocket at `GET /api/doorlock/ws`. ### Database Schema @@ -55,6 +60,12 @@ ### General +- Iris is a TanStack Start app: [apps/iris/src/router.tsx](apps/iris/src/router.tsx) builds a router (and QueryClient) per request, [apps/iris/src/client.tsx](apps/iris/src/client.tsx) hydrates it, [apps/iris/src/routes/__root.tsx](apps/iris/src/routes/__root.tsx) renders the document shell, and [apps/iris/server.ts](apps/iris/server.ts) is the production entry (Start handler + `dist/client` statics). `bun run build && bun run start` serves everything from one Bun process; there is no nginx. +- `ssr` defaults to `true`; the authenticated tree is `ssr: 'data-only'` on [apps/iris/src/routes/_private/route.tsx](apps/iris/src/routes/_private/route.tsx), which every descendant inherits. +- A route's `loader` prefetches with `prefetch(() => context.queryClient.ensureQueryData(orpc...queryOptions({ input })))` from [apps/iris/src/utils/orpc.ts](apps/iris/src/utils/orpc.ts) — the same call the component's hook makes, so hydration reads the cache instead of refetching. `prefetch` swallows a rejected prefetch because every screen is reachable without a session. Read search params from `location.searchStr` in a loader rather than adding `loaderDeps`: biome sorts the route options, and a `loaderDeps` above `validateSearch` pins the search type to `{}`. +- Per-request reads go through `createIsomorphicFn` thunks in [apps/iris/src/utils/request.ts](apps/iris/src/utils/request.ts) and [apps/iris/src/utils/i18n.ts](apps/iris/src/utils/i18n.ts) — the chain yields a value, so wrap it in an arrow to keep it callable per request. The oRPC client in [apps/iris/src/utils/orpc.ts](apps/iris/src/utils/orpc.ts) does the same, resolving the API origin and forwarding the request headers on the server. +- The session of a server render is read once in the root route and provided through `AuthSessionProvider` from `@filcdev/auth/client`; better-auth's own client only fetches a session in the browser. +- Keep a heavy island behind [apps/iris/src/components/lazy.tsx](apps/iris/src/components/lazy.tsx) when it only appears behind a dialog or a closed panel; `load` must be a module-level arrow. - Keep user-facing text in `t(...)` and update both locale trees under [apps/iris/public/locales/en](apps/iris/public/locales/en) and [apps/iris/public/locales/hu](apps/iris/public/locales/hu). - TanStack Form is the default form pattern: `useForm`, `useStore(form.store, selector)`, `{(field) => ...}` (see [apps/iris/src/components/doorlock/card-dialog.tsx](apps/iris/src/components/doorlock/card-dialog.tsx)). - `form.reset(values)` takes raw values, not `{ values }`; `form.reset` and `form.setFieldValue` are not stable `useEffect` dependencies, so omit them from dependency arrays. @@ -65,8 +76,8 @@ ### Data Flow - Domain data access lives in hook modules at [apps/iris/src/hooks](apps/iris/src/hooks) (one file per domain, e.g. `substitutions.ts`): `use` query hooks plus individual `useCreateX`/`useUpdateX`/`useDeleteX` mutation hooks. Hooks own the API call, translated success/error toasts, and invalidation of every affected query family. Mutation hooks accept `{ onSaved?: () => void }` for callers that need to react to success (e.g. closing a dialog). -- Routes contain no inline `parseResponse`/`useMutation` blocks; they consume the domain hooks and keep only UI state (sort, filters, dialog open/close, selection). Reference shape: [apps/iris/src/routes/_private/admin/timetable/substitutions.tsx](apps/iris/src/routes/_private/admin/timetable/substitutions.tsx). -- Always use centralized keys from [apps/iris/src/utils/query-keys.ts](apps/iris/src/utils/query-keys.ts); no inline array query keys for existing domains. Multi-parameter keys take a single object argument (e.g. `doorlock.logs({ deviceFilter, search, ... })`) so React Query partial matching works. +- Routes contain no inline API calls or `useMutation` blocks; they consume the domain hooks and keep only UI state (sort, filters, dialog open/close, selection). Reference shape: [apps/iris/src/routes/_private/admin/timetable/substitutions.tsx](apps/iris/src/routes/_private/admin/timetable/substitutions.tsx). +- Queries and mutations come from the generated utilities in [apps/iris/src/utils/orpc.ts](apps/iris/src/utils/orpc.ts): `useQuery({ ...orpc...queryOptions({ input }), enabled })` and `useMutation(orpc...mutationOptions({ onSuccess, onError }))`, then `mutate(flatInput)`. There are no hand-written query keys: invalidate with `queryClient.invalidateQueries({ queryKey: orpc...key({ input }) })`, and a bare `.key()` matches every input of that procedure. - Reuse [apps/iris/src/hooks/use-has-permission.ts](apps/iris/src/hooks/use-has-permission.ts) and existing permission guard components instead of duplicating permission logic in views. ### Dialogs And Forms @@ -79,7 +90,7 @@ ## Reuse And DRY -- Reuse existing helpers, types, schemas, and hooks before adding new ones. Check nearby feature folders first, then shared files: [apps/chronos/src/database/helpers.ts](apps/chronos/src/database/helpers.ts), [apps/iris/src/utils/query-keys.ts](apps/iris/src/utils/query-keys.ts), [packages/api/src/domains](packages/api/src/domains). +- Reuse existing helpers, types, schemas, and hooks before adding new ones. Check nearby feature folders first, then shared files: [apps/chronos/src/database/helpers.ts](apps/chronos/src/database/helpers.ts), [packages/api/src/contract](packages/api/src/contract), [packages/api/src/domains](packages/api/src/domains). - When a second call site needs the same logic, extract or extend the existing abstraction instead of creating a parallel helper with a slightly different name. - Keep abstractions local to the narrowest shared boundary that already exists. Do not create cross-app utilities for one feature-specific use. - Prefer the smallest root-cause fix that matches neighboring code over broad rewrites or speculative cleanup. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 700ffc16..5b17bd86 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -63,7 +63,7 @@ This guide gets you from a fresh clone to a running dev environment, then covers bun dev ``` - All three apps start via Turborepo. Iris runs through Vite, which regenerates `apps/iris/src/route-tree.gen.ts` automatically — never edit that file by hand. + All three apps start via Turborepo. Iris runs through Vite (TanStack Start), which regenerates `apps/iris/src/routeTree.gen.ts` automatically — never edit that file by hand. The kiosk app is a static bundle on a box, so it never relies on a proxied `/api`: dev calls `http://localhost:3001/api` directly and a production build diff --git a/README.md b/README.md index 08ca10d1..d40d4ff2 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,9 @@ | Path | Description | | -------------- | ------------------------------------------------------------------------------------------------------- | -| `apps/chronos` | Hono + Drizzle backend: API, auth (better-auth), scheduled jobs | +| `apps/chronos` | oRPC + Drizzle backend: implements the shared API contract, auth (better-auth), scheduled jobs | | `apps/iris` | React 19 + Vite frontend: TanStack Router / Query / Form | -| `packages/api` | Shared API contract (`@filcdev/api`): zod wire schemas, error types, permission constants, typed client | +| `packages/api` | Shared API contract (`@filcdev/api`): the oRPC contract, zod wire schemas, error map, permissions, typed client | ## Quickstart diff --git a/apps/chronos/openapi/chronos-openapi.json b/apps/chronos/openapi/chronos-openapi.json new file mode 100644 index 00000000..7f694c61 --- /dev/null +++ b/apps/chronos/openapi/chronos-openapi.json @@ -0,0 +1,44593 @@ +{ + "components": { + "securitySchemes": { + "sessionAuth": { + "in": "cookie", + "name": "filc.session_token", + "type": "apiKey" + } + } + }, + "info": { + "description": "API for consumption by the Filc app family.", + "title": "Chronos backend API", + "version": "0.0.1" + }, + "servers": [ + { + "description": "chronos", + "url": "https://filc.petrik.hu/api" + }, + { + "description": "Local Server", + "url": "http://localhost:3000/api" + } + ], + "openapi": "3.1.1", + "paths": { + "/bug-report": { + "post": { + "operationId": "postBugReport", + "description": "Create a new bug report", + "tags": [ + "Bug Reports" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "description": { + "type": "string", + "minLength": 10, + "maxLength": 5000 + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "page": { + "type": "string", + "maxLength": 255 + }, + "subject": { + "type": "string", + "minLength": 3, + "maxLength": 200 + } + }, + "required": [ + "description", + "subject" + ] + } + } + } + }, + "responses": { + "201": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "id" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "BugReport", + "x-filc_type_name": "@unit BugReport", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + }, + "get": { + "operationId": "getBugReport", + "description": "List bug reports with optional filters and paging", + "tags": [ + "Bug Reports" + ], + "parameters": [ + { + "name": "dateFrom", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + }, + "allowEmptyValue": true, + "allowReserved": true + }, + { + "name": "dateTo", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + }, + "allowEmptyValue": true, + "allowReserved": true + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + }, + "allowEmptyValue": true, + "allowReserved": true + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991, + "default": 1 + }, + "allowEmptyValue": true, + "allowReserved": true + }, + { + "name": "search", + "in": "query", + "schema": { + "type": "string" + }, + "allowEmptyValue": true, + "allowReserved": true + }, + { + "name": "status", + "in": "query", + "schema": { + "enum": [ + "open", + "in_progress", + "resolved", + "closed" + ], + "type": "string" + }, + "allowEmptyValue": true, + "allowReserved": true + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "reports": { + "type": "array", + "items": { + "type": "object", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "description": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "metadata": {}, + "page": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "reporterEmail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "reporterId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + }, + "status": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "createdAt", + "description", + "id", + "page", + "reporterEmail", + "reporterId", + "status", + "subject", + "updatedAt" + ] + } + }, + "total": { + "type": "number" + } + }, + "required": [ + "reports", + "total" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "BugReport", + "x-filc_type_name": "@unit BugReportListResponse @field(.reports, List)", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/bug-report/{id}": { + "delete": { + "operationId": "deleteBugReportById", + "description": "Delete a bug report", + "tags": [ + "Bug Reports" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "id" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "BugReport", + "x-filc_type_name": "@nodata", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/bug-report/{id}/status": { + "patch": { + "operationId": "patchBugReportByIdStatus", + "description": "Update a bug report status", + "tags": [ + "Bug Reports" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "enum": [ + "open", + "in_progress", + "resolved", + "closed" + ], + "type": "string" + } + }, + "required": [ + "status" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "description": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "metadata": {}, + "page": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "reporterEmail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "reporterId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + }, + "status": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "createdAt", + "description", + "id", + "page", + "reporterEmail", + "reporterId", + "status", + "subject", + "updatedAt" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "BugReport", + "x-filc_type_name": "@unit BugReport", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/cohort": { + "get": { + "operationId": "getCohort", + "description": "List all cohorts", + "tags": [ + "Cohort" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "classroomIds": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + }, + "teacherId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "timetableId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "classroomIds", + "id", + "name", + "short", + "teacherId", + "timetableId" + ] + } + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Cohort", + "x-filc_type_name": "@listof Cohort" + } + }, + "/dashboard/stats": { + "get": { + "operationId": "getDashboardStats", + "description": "Get aggregated dashboard statistics", + "tags": [ + "Dashboard" + ], + "parameters": [ + { + "name": "days", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 365, + "default": 30 + }, + "allowEmptyValue": true, + "allowReserved": true + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "stats": { + "type": "object", + "properties": { + "chartData": { + "type": "array", + "items": { + "type": "object", + "properties": { + "date": { + "type": "string" + }, + "movedLessons": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "substitutions": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "date", + "movedLessons", + "substitutions" + ] + } + }, + "chartTotalMovedLessons": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "chartTotalSubstitutions": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "totalCohorts": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "totalMovedLessons": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "totalRoles": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "totalSubstitutions": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "totalUsers": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "chartData", + "chartTotalMovedLessons", + "chartTotalSubstitutions", + "totalCohorts", + "totalMovedLessons", + "totalRoles", + "totalSubstitutions", + "totalUsers" + ] + } + }, + "required": [ + "stats" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Dashboard", + "x-filc_type_name": "@unit DashboardStatsResponse" + } + }, + "/doorlock/cards": { + "post": { + "operationId": "postDoorlockCards", + "description": "Create a new access card", + "tags": [ + "Doorlock" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "authorizedDeviceIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "default": [] + }, + "enabled": { + "type": "boolean", + "default": true + }, + "frozen": { + "type": "boolean", + "default": false + }, + "name": { + "type": "string", + "minLength": 1 + }, + "cardData": { + "type": "string", + "minLength": 1 + }, + "userId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "name", + "cardData", + "userId" + ] + } + } + } + }, + "responses": { + "201": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "card": { + "type": "object", + "properties": { + "authorizedDevices": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + } + }, + "cardData": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "enabled": { + "type": "boolean" + }, + "frozen": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "owner": { + "anyOf": [ + { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "nickname": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "email", + "id", + "name", + "nickname" + ] + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "userId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "authorizedDevices", + "cardData", + "createdAt", + "enabled", + "frozen", + "id", + "name", + "updatedAt", + "userId" + ] + } + }, + "required": [ + "card" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Doorlock", + "x-filc_type_name": "@unit CardResponse @field(.card, Card)", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + }, + "get": { + "operationId": "getDoorlockCards", + "description": "List all access cards", + "tags": [ + "Doorlock" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "cards": { + "type": "array", + "items": { + "type": "object", + "properties": { + "authorizedDevices": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + } + }, + "cardData": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "enabled": { + "type": "boolean" + }, + "frozen": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "owner": { + "anyOf": [ + { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "nickname": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "email", + "id", + "name", + "nickname" + ] + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "userId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "authorizedDevices", + "cardData", + "createdAt", + "enabled", + "frozen", + "id", + "name", + "updatedAt", + "userId" + ] + } + } + }, + "required": [ + "cards" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Doorlock", + "x-filc_type_name": "@unit CardListResponse @field(.cards, List)", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/doorlock/cards/{id}": { + "delete": { + "operationId": "deleteDoorlockCardsById", + "description": "Delete an access card", + "tags": [ + "Doorlock" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "id" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Doorlock", + "x-filc_type_name": "@nodata", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + }, + "put": { + "operationId": "putDoorlockCardsById", + "description": "Update an access card", + "tags": [ + "Doorlock" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "authorizedDeviceIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "default": [] + }, + "enabled": { + "type": "boolean", + "default": true + }, + "frozen": { + "type": "boolean", + "default": false + }, + "name": { + "type": "string", + "minLength": 1 + }, + "userId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "name", + "userId" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "card": { + "type": "object", + "properties": { + "authorizedDevices": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + } + }, + "cardData": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "enabled": { + "type": "boolean" + }, + "frozen": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "owner": { + "anyOf": [ + { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "nickname": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "email", + "id", + "name", + "nickname" + ] + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "userId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "authorizedDevices", + "cardData", + "createdAt", + "enabled", + "frozen", + "id", + "name", + "updatedAt", + "userId" + ] + } + }, + "required": [ + "card" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Doorlock", + "x-filc_type_name": "@unit CardResponse @field(.card, Card)", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/doorlock/cards/users": { + "get": { + "operationId": "getDoorlockCardsUsers", + "description": "List users eligible for card ownership", + "tags": [ + "Doorlock" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "users": { + "type": "array", + "items": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "nickname": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "email", + "id", + "name", + "nickname" + ] + } + } + }, + "required": [ + "users" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Doorlock", + "x-filc_type_name": "@unit DoorlockUserListResponse @field(.users, List)", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/doorlock/devices": { + "post": { + "operationId": "postDoorlockDevices", + "description": "Create a new doorlock device", + "tags": [ + "Doorlock" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "apiToken": { + "type": "string", + "minLength": 1 + }, + "lastResetReason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "location": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "apiToken", + "name" + ] + } + } + } + }, + "responses": { + "201": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "device": { + "type": "object", + "properties": { + "apiToken": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "lastResetReason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "location": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "apiToken", + "createdAt", + "id", + "lastResetReason", + "location", + "name", + "updatedAt" + ] + } + }, + "required": [ + "device" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Doorlock", + "x-filc_type_name": "@unit DeviceResponse @field(.device, Device)", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + }, + "get": { + "operationId": "getDoorlockDevices", + "description": "List all doorlock devices", + "tags": [ + "Doorlock" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "devices": { + "type": "array", + "items": { + "type": "object", + "properties": { + "apiToken": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "lastResetReason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "location": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "apiToken", + "createdAt", + "id", + "lastResetReason", + "location", + "name", + "updatedAt" + ] + } + } + }, + "required": [ + "devices" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Doorlock", + "x-filc_type_name": "@unit DeviceListResponse @field(.devices, List)", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/doorlock/devices/{id}": { + "delete": { + "operationId": "deleteDoorlockDevicesById", + "description": "Delete a doorlock device", + "tags": [ + "Doorlock" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "id" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Doorlock", + "x-filc_type_name": "@nodata", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + }, + "put": { + "operationId": "putDoorlockDevicesById", + "description": "Update an existing doorlock device", + "tags": [ + "Doorlock" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "apiToken": { + "type": "string", + "minLength": 1 + }, + "lastResetReason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "location": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "apiToken", + "name" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "device": { + "type": "object", + "properties": { + "apiToken": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "lastResetReason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "location": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "apiToken", + "createdAt", + "id", + "lastResetReason", + "location", + "name", + "updatedAt" + ] + } + }, + "required": [ + "device" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Doorlock", + "x-filc_type_name": "@unit DeviceResponse @field(.device, Device)", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/doorlock/devices/{id}/stats": { + "get": { + "operationId": "getDoorlockDevicesByIdStats", + "description": "Get device health statistics", + "tags": [ + "Doorlock" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "deviceMeta": { + "type": "object", + "properties": { + "debug": { + "type": "object", + "properties": { + "deviceState": { + "enum": [ + "booting", + "error", + "idle", + "updating" + ], + "type": "string" + }, + "errors": { + "type": "object", + "properties": { + "db": { + "type": "boolean" + }, + "nfc": { + "type": "boolean" + }, + "ota": { + "type": "boolean" + }, + "sd": { + "type": "boolean" + }, + "wifi": { + "type": "boolean" + } + }, + "required": [ + "db", + "nfc", + "ota", + "sd", + "wifi" + ] + }, + "lastResetReason": { + "type": "string" + } + }, + "required": [ + "deviceState", + "errors", + "lastResetReason" + ] + }, + "fwVersion": { + "type": "string" + }, + "ramFree": { + "type": "number" + }, + "storage": { + "type": "object", + "properties": { + "total": { + "type": "number" + }, + "used": { + "type": "number" + } + }, + "required": [ + "total", + "used" + ] + }, + "uptime": { + "type": "number" + } + }, + "required": [ + "debug", + "fwVersion", + "ramFree", + "storage", + "uptime" + ] + }, + "id": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "timestamp": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "deviceMeta", + "id", + "timestamp" + ] + } + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Doorlock", + "x-filc_type_name": "@unit DeviceStatsResponse @field(.stats, List)", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/doorlock/devices/{id}/update": { + "post": { + "operationId": "postDoorlockDevicesByIdUpdate", + "description": "Trigger an OTA update on a specific device", + "tags": [ + "Doorlock" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "url" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "const": true + } + }, + "required": [ + "ok" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + } + } + }, + "/doorlock/devices/update": { + "post": { + "operationId": "postDoorlockDevicesUpdate", + "description": "Trigger an OTA update on all devices", + "tags": [ + "Doorlock" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "url" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "count" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + } + } + }, + "/doorlock/logs/export": { + "get": { + "operationId": "getDoorlockLogsExport", + "description": "Export doorlock audit log entries (attendance data) as a CSV file over an optional date range.", + "tags": [ + "Doorlock" + ], + "parameters": [ + { + "name": "cardId", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + }, + "allowEmptyValue": true, + "allowReserved": true + }, + { + "name": "deviceId", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + }, + "allowEmptyValue": true, + "allowReserved": true + }, + { + "name": "from", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + }, + "allowEmptyValue": true, + "allowReserved": true + }, + { + "name": "granted", + "in": "query", + "schema": { + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "allowEmptyValue": true, + "allowReserved": true + }, + { + "name": "search", + "in": "query", + "schema": { + "type": "string" + }, + "allowEmptyValue": true, + "allowReserved": true + }, + { + "name": "to", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + }, + "allowEmptyValue": true, + "allowReserved": true + }, + { + "name": "userId", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + }, + "allowEmptyValue": true, + "allowReserved": true + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "string", + "contentMediaType": "*/*" + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Doorlock", + "x-filc_type_name": "Export audit log entries as CSV", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/doorlock/logs": { + "get": { + "operationId": "getDoorlockLogs", + "description": "List audit log entries", + "tags": [ + "Doorlock" + ], + "parameters": [ + { + "name": "cardId", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + }, + "allowEmptyValue": true, + "allowReserved": true + }, + { + "name": "deviceId", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + }, + "allowEmptyValue": true, + "allowReserved": true + }, + { + "name": "from", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + }, + "allowEmptyValue": true, + "allowReserved": true + }, + { + "name": "granted", + "in": "query", + "schema": { + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "allowEmptyValue": true, + "allowReserved": true + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 500 + }, + "allowEmptyValue": true, + "allowReserved": true + }, + { + "name": "search", + "in": "query", + "schema": { + "type": "string" + }, + "allowEmptyValue": true, + "allowReserved": true + }, + { + "name": "to", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + }, + "allowEmptyValue": true, + "allowReserved": true + }, + { + "name": "userId", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + }, + "allowEmptyValue": true, + "allowReserved": true + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "logs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "buttonPressed": { + "type": "boolean" + }, + "cardData": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "cardId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + }, + "deviceId": { + "type": "string", + "format": "uuid" + }, + "id": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "result": { + "type": "boolean" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "userId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + }, + "card": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + }, + { + "type": "null" + } + ] + }, + "device": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + }, + { + "type": "null" + } + ] + }, + "owner": { + "anyOf": [ + { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "nickname": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "email", + "id", + "name", + "nickname" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "buttonPressed", + "cardData", + "cardId", + "deviceId", + "id", + "result", + "timestamp", + "userId" + ] + } + } + }, + "required": [ + "logs" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Doorlock", + "x-filc_type_name": "@unit DoorlockLogListResponse @field(.logs, List)", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/doorlock/self/cards/{id}/activate": { + "post": { + "operationId": "postDoorlockSelfCardsByIdActivate", + "description": "Activate an authorized device using a user-owned virtual card", + "tags": [ + "Doorlock" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "deviceId": { + "type": "string", + "format": "uuid" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "log": { + "type": "object", + "properties": { + "buttonPressed": { + "type": "boolean" + }, + "cardData": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "cardId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + }, + "deviceId": { + "type": "string", + "format": "uuid" + }, + "id": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "result": { + "type": "boolean" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "userId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "buttonPressed", + "cardData", + "cardId", + "deviceId", + "id", + "result", + "timestamp", + "userId" + ] + } + }, + "required": [ + "log" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Doorlock", + "x-filc_type_name": "@unit DoorlockActivationResponse @field(.log, AuditLog)", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/doorlock/self/cards": { + "get": { + "operationId": "getDoorlockSelfCards", + "description": "List cards owned by the authenticated user", + "tags": [ + "Doorlock" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "cards": { + "type": "array", + "items": { + "type": "object", + "properties": { + "authorizedDevices": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + } + }, + "cardData": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "enabled": { + "type": "boolean" + }, + "frozen": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "owner": { + "anyOf": [ + { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "nickname": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "email", + "id", + "name", + "nickname" + ] + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "userId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "authorizedDevices", + "cardData", + "createdAt", + "enabled", + "frozen", + "id", + "name", + "updatedAt", + "userId" + ] + } + } + }, + "required": [ + "cards" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Doorlock", + "x-filc_type_name": "@unit CardListResponse @field(.cards, List)", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/doorlock/self/cards/{id}/frozen": { + "put": { + "operationId": "putDoorlockSelfCardsByIdFrozen", + "description": "Update the frozen state of a user-owned card", + "tags": [ + "Doorlock" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "frozen": { + "type": "boolean" + } + }, + "required": [ + "frozen" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "card": { + "type": "object", + "properties": { + "authorizedDevices": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + } + }, + "cardData": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "enabled": { + "type": "boolean" + }, + "frozen": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "owner": { + "anyOf": [ + { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "nickname": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "email", + "id", + "name", + "nickname" + ] + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "userId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "authorizedDevices", + "cardData", + "createdAt", + "enabled", + "frozen", + "id", + "name", + "updatedAt", + "userId" + ] + } + }, + "required": [ + "card" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Doorlock", + "x-filc_type_name": "@unit CardResponse @field(.card, Card)", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/doorlock/stats/overview": { + "get": { + "operationId": "getDoorlockStatsOverview", + "description": "Get aggregated doorlock statistics", + "tags": [ + "Doorlock" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "stats": { + "type": "object", + "properties": { + "doorOpenSeries": { + "type": "array", + "items": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "date": { + "type": "string" + } + }, + "required": [ + "count", + "date" + ] + } + }, + "topUsers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "id": { + "type": "string" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nickname": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "count", + "id", + "name", + "nickname" + ] + } + }, + "totalCards": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "totalDevices": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "totalSuccessfulOpens": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "doorOpenSeries", + "topUsers", + "totalCards", + "totalDevices", + "totalSuccessfulOpens" + ] + } + }, + "required": [ + "stats" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Doorlock", + "x-filc_type_name": "@unit DoorlockStatsResponse @field(.stats, DoorlockStats)", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/health": { + "get": { + "operationId": "getHealth", + "description": "Readiness probe: answers 200 only when PostgreSQL is reachable.", + "tags": [ + "Health" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "database": { + "const": "up" + }, + "status": { + "const": "ok" + } + }, + "required": [ + "database", + "status" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Health", + "x-filc_type_name": "@unit HealthResponse" + } + }, + "/kiosk": { + "post": { + "operationId": "postKiosk", + "description": "Register a kiosk", + "tags": [ + "Kiosk" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "config": {}, + "kind": { + "enum": [ + "tv", + "navigator" + ], + "type": "string" + }, + "machineId": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "name": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "kind", + "machineId", + "name" + ] + } + } + } + }, + "responses": { + "201": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "kiosk": { + "type": "object", + "properties": { + "appVersion": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "config": {}, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "kind": { + "type": "string" + }, + "lastSeenAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + { + "type": "null" + } + ] + }, + "lastSeenIp": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "machineId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "appVersion", + "createdAt", + "enabled", + "id", + "kind", + "lastSeenAt", + "lastSeenIp", + "machineId", + "name", + "updatedAt" + ] + } + }, + "required": [ + "kiosk" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Kiosk", + "x-filc_type_name": "@unit KioskResponse @field(.kiosk, Kiosk)", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + }, + "get": { + "operationId": "getKiosk", + "description": "List all kiosks", + "tags": [ + "Kiosk" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "kiosks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "appVersion": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "config": {}, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "kind": { + "type": "string" + }, + "lastSeenAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + { + "type": "null" + } + ] + }, + "lastSeenIp": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "machineId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "appVersion", + "createdAt", + "enabled", + "id", + "kind", + "lastSeenAt", + "lastSeenIp", + "machineId", + "name", + "updatedAt" + ] + } + } + }, + "required": [ + "kiosks" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Kiosk", + "x-filc_type_name": "@unit KioskListResponse @field(.kiosks, List)", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/kiosk/{id}": { + "delete": { + "operationId": "deleteKioskById", + "description": "Delete a kiosk", + "tags": [ + "Kiosk" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "kiosk": { + "type": "object", + "properties": { + "appVersion": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "config": {}, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "kind": { + "type": "string" + }, + "lastSeenAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + { + "type": "null" + } + ] + }, + "lastSeenIp": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "machineId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "appVersion", + "createdAt", + "enabled", + "id", + "kind", + "lastSeenAt", + "lastSeenIp", + "machineId", + "name", + "updatedAt" + ] + } + }, + "required": [ + "kiosk" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Kiosk", + "x-filc_type_name": "@unit KioskResponse @field(.kiosk, Kiosk)", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + }, + "put": { + "operationId": "putKioskById", + "description": "Update a kiosk", + "tags": [ + "Kiosk" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "config": {}, + "enabled": { + "type": "boolean" + }, + "kind": { + "enum": [ + "tv", + "navigator" + ], + "type": "string" + }, + "name": { + "type": "string", + "minLength": 1 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "kiosk": { + "type": "object", + "properties": { + "appVersion": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "config": {}, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "kind": { + "type": "string" + }, + "lastSeenAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + { + "type": "null" + } + ] + }, + "lastSeenIp": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "machineId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "appVersion", + "createdAt", + "enabled", + "id", + "kind", + "lastSeenAt", + "lastSeenIp", + "machineId", + "name", + "updatedAt" + ] + } + }, + "required": [ + "kiosk" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Kiosk", + "x-filc_type_name": "@unit KioskResponse @field(.kiosk, Kiosk)", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/kiosk/departures": { + "post": { + "operationId": "postKioskDepartures", + "description": "Next departure per configured group, trying each stop in order", + "tags": [ + "Kiosk" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "groups": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1 + }, + "stops": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "routeFilter": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ], + "default": null + }, + "stopId": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "stopId" + ] + } + } + }, + "required": [ + "label", + "stops" + ] + } + } + }, + "required": [ + "groups" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "departures": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "predictedDepartureTime": { + "type": "number" + }, + "routeShortDesc": { + "type": "string" + } + }, + "required": [ + "label", + "predictedDepartureTime", + "routeShortDesc" + ] + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "departures" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Kiosk", + "x-filc_type_name": "@unit KioskDeparturesResponse @field(.departures, List)" + } + }, + "/kiosk/heartbeat": { + "post": { + "operationId": "postKioskHeartbeat", + "description": "Record a kiosk heartbeat and report whether the box is registered and enabled", + "tags": [ + "Kiosk" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "appVersion": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "machineId": { + "type": "string", + "minLength": 1, + "maxLength": 64 + } + }, + "required": [ + "appVersion", + "machineId" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "registered": { + "const": false + } + }, + "required": [ + "registered" + ] + }, + { + "type": "object", + "properties": { + "enabled": { + "const": false + }, + "kiosk": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "kind": { + "enum": [ + "tv", + "navigator" + ], + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "kind", + "name" + ] + }, + "registered": { + "const": true + } + }, + "required": [ + "enabled", + "kiosk", + "registered" + ] + }, + { + "type": "object", + "properties": { + "enabled": { + "const": true + }, + "kiosk": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "kind": { + "enum": [ + "tv", + "navigator" + ], + "type": "string" + }, + "name": { + "type": "string" + }, + "config": {} + }, + "required": [ + "id", + "kind", + "name" + ] + }, + "registered": { + "const": true + } + }, + "required": [ + "enabled", + "kiosk", + "registered" + ] + } + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Kiosk", + "x-filc_type_name": "@unit KioskHeartbeatResponse @field(.kiosk, Kiosk)" + } + }, + "/kiosk/news/{id}/image": { + "get": { + "operationId": "getKioskNewsByIdImage", + "description": "The image an announcement shows on the kiosk, as stored", + "tags": [ + "Kiosk" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "string", + "contentMediaType": "*/*" + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Kiosk", + "x-filc_type_name": "@unit KioskNewsImage" + } + }, + "/kiosk/news": { + "get": { + "operationId": "getKioskNews", + "description": "Active, titled, non-cohort-scoped announcements for the TV kiosk marquee", + "tags": [ + "Kiosk" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "announcements": { + "type": "array", + "items": { + "type": "object", + "properties": { + "body": { + "type": "string" + }, + "highlighted": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "imageVersion": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "kioskIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "type": "string" + }, + "validFrom": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "validUntil": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "body", + "highlighted", + "id", + "imageVersion", + "kioskIds", + "title", + "validFrom", + "validUntil" + ] + } + } + }, + "required": [ + "announcements" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Kiosk", + "x-filc_type_name": "@unit KioskNewsResponse @field(.announcements, List)" + } + }, + "/kiosk/petrik-news": { + "get": { + "operationId": "getKioskPetrikNews", + "description": "petrik.hu news for the navigator kiosk, cached for ten minutes", + "tags": [ + "Kiosk" + ], + "parameters": [ + { + "name": "machine", + "in": "query", + "schema": { + "type": "string", + "minLength": 1 + }, + "allowEmptyValue": true, + "allowReserved": true + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "body": { + "type": "string" + }, + "imageUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "publishedAt": { + "type": "string" + }, + "title": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "body", + "imageUrl", + "publishedAt", + "title", + "url" + ] + } + } + }, + "required": [ + "items" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Kiosk", + "x-filc_type_name": "@unit KioskPetrikNewsResponse" + } + }, + "/kiosk/weather": { + "get": { + "operationId": "getKioskWeather", + "description": "Current weather for the TV kiosk, cached for ten minutes", + "tags": [ + "Kiosk" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "icon": { + "type": "string" + }, + "precipMm": { + "type": "number" + }, + "tempC": { + "type": "number" + }, + "windKph": { + "type": "number" + } + }, + "required": [ + "icon", + "precipMm", + "tempC", + "windKph" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Kiosk", + "x-filc_type_name": "@unit KioskWeatherResponse" + } + }, + "/navigator/buildings": { + "post": { + "operationId": "postNavigatorBuildings", + "description": "Create a campus building", + "tags": [ + "Navigator" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "description", + "name", + "x", + "y" + ] + } + } + } + }, + "responses": { + "201": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "building": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mapped": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "description", + "id", + "mapped", + "name", + "x", + "y" + ] + } + }, + "required": [ + "building" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Navigator", + "x-filc_type_name": "@unit Building", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + }, + "get": { + "operationId": "getNavigatorBuildings", + "description": "List the campus buildings", + "tags": [ + "Navigator" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "buildings": { + "type": "array", + "items": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mapped": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "description", + "id", + "mapped", + "name", + "x", + "y" + ] + } + } + }, + "required": [ + "buildings" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Navigator", + "x-filc_type_name": "@listof Building" + } + }, + "/navigator/buildings/{id}": { + "delete": { + "operationId": "deleteNavigatorBuildingsById", + "description": "Delete a campus building; its corridors, lifts and stairs cascade with it, but rooms still assigned to it refuse the delete", + "tags": [ + "Navigator" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "building": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mapped": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "description", + "id", + "mapped", + "name", + "x", + "y" + ] + } + }, + "required": [ + "building" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Navigator", + "x-filc_type_name": "@unit Building", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + }, + "put": { + "operationId": "putNavigatorBuildingsById", + "description": "Update a campus building", + "tags": [ + "Navigator" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + } + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "building": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mapped": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "description", + "id", + "mapped", + "name", + "x", + "y" + ] + } + }, + "required": [ + "building" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Navigator", + "x-filc_type_name": "@unit Building", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/navigator/classrooms": { + "post": { + "operationId": "postNavigatorClassrooms", + "description": "Create a classroom", + "tags": [ + "Navigator" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "building_id": { + "type": "string", + "minLength": 1 + }, + "capacity": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "description": { + "type": "string" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "rotation": { + "type": "number" + }, + "short": { + "type": "string" + }, + "size_x": { + "type": "number" + }, + "size_y": { + "type": "number" + }, + "size_z": { + "type": "number" + }, + "storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "type_id": { + "type": "string", + "minLength": 1 + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "building_id", + "capacity", + "description", + "name", + "rotation", + "short", + "size_x", + "size_y", + "size_z", + "storey", + "type_id", + "x", + "y" + ] + } + } + } + }, + "responses": { + "201": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "classroom": { + "type": "object", + "properties": { + "building_id": { + "type": "string" + }, + "capacity": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mapped": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "rotation": { + "type": "number" + }, + "short": { + "type": "string" + }, + "size_x": { + "type": "number" + }, + "size_y": { + "type": "number" + }, + "size_z": { + "type": "number" + }, + "storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "type_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "building_id", + "capacity", + "description", + "id", + "mapped", + "name", + "rotation", + "short", + "size_x", + "size_y", + "size_z", + "storey", + "type_id", + "x", + "y" + ] + } + }, + "required": [ + "classroom" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Navigator", + "x-filc_type_name": "@unit Classroom", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + }, + "get": { + "operationId": "getNavigatorClassrooms", + "description": "List the classrooms, including the ones the campus has not placed yet", + "tags": [ + "Navigator" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "classrooms": { + "type": "array", + "items": { + "type": "object", + "properties": { + "building_id": { + "type": "string" + }, + "capacity": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mapped": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "rotation": { + "type": "number" + }, + "short": { + "type": "string" + }, + "size_x": { + "type": "number" + }, + "size_y": { + "type": "number" + }, + "size_z": { + "type": "number" + }, + "storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "type_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "building_id", + "capacity", + "description", + "id", + "mapped", + "name", + "rotation", + "short", + "size_x", + "size_y", + "size_z", + "storey", + "type_id", + "x", + "y" + ] + } + } + }, + "required": [ + "classrooms" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Navigator", + "x-filc_type_name": "@listof Classroom" + } + }, + "/navigator/classrooms/{id}": { + "delete": { + "operationId": "deleteNavigatorClassroomsById", + "description": "Delete a classroom; rooms the timetable still uses are refused", + "tags": [ + "Navigator" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "classroom": { + "type": "object", + "properties": { + "building_id": { + "type": "string" + }, + "capacity": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mapped": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "rotation": { + "type": "number" + }, + "short": { + "type": "string" + }, + "size_x": { + "type": "number" + }, + "size_y": { + "type": "number" + }, + "size_z": { + "type": "number" + }, + "storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "type_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "building_id", + "capacity", + "description", + "id", + "mapped", + "name", + "rotation", + "short", + "size_x", + "size_y", + "size_z", + "storey", + "type_id", + "x", + "y" + ] + } + }, + "required": [ + "classroom" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Navigator", + "x-filc_type_name": "@unit Classroom", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + }, + "put": { + "operationId": "putNavigatorClassroomsById", + "description": "Update a classroom", + "tags": [ + "Navigator" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + } + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "building_id": { + "type": "string", + "minLength": 1 + }, + "capacity": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "description": { + "type": "string" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "rotation": { + "type": "number" + }, + "short": { + "type": "string" + }, + "size_x": { + "type": "number" + }, + "size_y": { + "type": "number" + }, + "size_z": { + "type": "number" + }, + "storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "type_id": { + "type": "string", + "minLength": 1 + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "classroom": { + "type": "object", + "properties": { + "building_id": { + "type": "string" + }, + "capacity": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mapped": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "rotation": { + "type": "number" + }, + "short": { + "type": "string" + }, + "size_x": { + "type": "number" + }, + "size_y": { + "type": "number" + }, + "size_z": { + "type": "number" + }, + "storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "type_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "building_id", + "capacity", + "description", + "id", + "mapped", + "name", + "rotation", + "short", + "size_x", + "size_y", + "size_z", + "storey", + "type_id", + "x", + "y" + ] + } + }, + "required": [ + "classroom" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Navigator", + "x-filc_type_name": "@unit Classroom", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/navigator/classroom-types": { + "post": { + "operationId": "postNavigatorClassroomTypes", + "description": "Create a classroom type", + "tags": [ + "Navigator" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "colorhex": { + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}$" + }, + "name": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "colorhex", + "name" + ] + } + } + } + }, + "responses": { + "201": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "classroom_type": { + "type": "object", + "properties": { + "colorhex": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "colorhex", + "createdAt", + "id", + "name", + "updatedAt" + ] + } + }, + "required": [ + "classroom_type" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Navigator", + "x-filc_type_name": "@unit ClassroomType", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + }, + "get": { + "operationId": "getNavigatorClassroomTypes", + "description": "List the classroom types", + "tags": [ + "Navigator" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "classroom_types": { + "type": "array", + "items": { + "type": "object", + "properties": { + "colorhex": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "colorhex", + "createdAt", + "id", + "name", + "updatedAt" + ] + } + } + }, + "required": [ + "classroom_types" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Navigator", + "x-filc_type_name": "@listof ClassroomType" + } + }, + "/navigator/classroom-types/{id}": { + "delete": { + "operationId": "deleteNavigatorClassroomTypesById", + "description": "Delete a classroom type; types still assigned to a classroom are refused", + "tags": [ + "Navigator" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "classroom_type": { + "type": "object", + "properties": { + "colorhex": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "colorhex", + "createdAt", + "id", + "name", + "updatedAt" + ] + } + }, + "required": [ + "classroom_type" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Navigator", + "x-filc_type_name": "@unit ClassroomType", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + }, + "put": { + "operationId": "putNavigatorClassroomTypesById", + "description": "Update a classroom type", + "tags": [ + "Navigator" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + } + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "colorhex": { + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}$" + }, + "name": { + "type": "string", + "minLength": 1 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "classroom_type": { + "type": "object", + "properties": { + "colorhex": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "colorhex", + "createdAt", + "id", + "name", + "updatedAt" + ] + } + }, + "required": [ + "classroom_type" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Navigator", + "x-filc_type_name": "@unit ClassroomType", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/navigator/corridors": { + "post": { + "operationId": "postNavigatorCorridors", + "description": "Create a corridor", + "tags": [ + "Navigator" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "barrier_free": { + "type": "boolean" + }, + "building_id": { + "type": "string", + "minLength": 1 + }, + "is_outdoor": { + "type": "boolean" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "width": { + "type": "number" + }, + "x1": { + "type": "number" + }, + "x2": { + "type": "number" + }, + "y1": { + "type": "number" + }, + "y2": { + "type": "number" + } + }, + "required": [ + "barrier_free", + "building_id", + "is_outdoor", + "name", + "storey", + "width", + "x1", + "x2", + "y1", + "y2" + ] + } + } + } + }, + "responses": { + "201": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "corridor": { + "type": "object", + "properties": { + "barrier_free": { + "type": "boolean" + }, + "building_id": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string" + }, + "is_outdoor": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "width": { + "type": "number" + }, + "x1": { + "type": "number" + }, + "x2": { + "type": "number" + }, + "y1": { + "type": "number" + }, + "y2": { + "type": "number" + } + }, + "required": [ + "barrier_free", + "building_id", + "createdAt", + "id", + "is_outdoor", + "name", + "storey", + "updatedAt", + "width", + "x1", + "x2", + "y1", + "y2" + ] + } + }, + "required": [ + "corridor" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Navigator", + "x-filc_type_name": "@unit Corridor", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + }, + "get": { + "operationId": "getNavigatorCorridors", + "description": "List the corridors", + "tags": [ + "Navigator" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "corridors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "barrier_free": { + "type": "boolean" + }, + "building_id": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string" + }, + "is_outdoor": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "width": { + "type": "number" + }, + "x1": { + "type": "number" + }, + "x2": { + "type": "number" + }, + "y1": { + "type": "number" + }, + "y2": { + "type": "number" + } + }, + "required": [ + "barrier_free", + "building_id", + "createdAt", + "id", + "is_outdoor", + "name", + "storey", + "updatedAt", + "width", + "x1", + "x2", + "y1", + "y2" + ] + } + } + }, + "required": [ + "corridors" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Navigator", + "x-filc_type_name": "@listof Corridor" + } + }, + "/navigator/corridors/{id}": { + "delete": { + "operationId": "deleteNavigatorCorridorsById", + "description": "Delete a corridor", + "tags": [ + "Navigator" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "corridor": { + "type": "object", + "properties": { + "barrier_free": { + "type": "boolean" + }, + "building_id": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string" + }, + "is_outdoor": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "width": { + "type": "number" + }, + "x1": { + "type": "number" + }, + "x2": { + "type": "number" + }, + "y1": { + "type": "number" + }, + "y2": { + "type": "number" + } + }, + "required": [ + "barrier_free", + "building_id", + "createdAt", + "id", + "is_outdoor", + "name", + "storey", + "updatedAt", + "width", + "x1", + "x2", + "y1", + "y2" + ] + } + }, + "required": [ + "corridor" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Navigator", + "x-filc_type_name": "@unit Corridor", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + }, + "put": { + "operationId": "putNavigatorCorridorsById", + "description": "Update a corridor", + "tags": [ + "Navigator" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + } + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "barrier_free": { + "type": "boolean" + }, + "building_id": { + "type": "string", + "minLength": 1 + }, + "is_outdoor": { + "type": "boolean" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "width": { + "type": "number" + }, + "x1": { + "type": "number" + }, + "x2": { + "type": "number" + }, + "y1": { + "type": "number" + }, + "y2": { + "type": "number" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "corridor": { + "type": "object", + "properties": { + "barrier_free": { + "type": "boolean" + }, + "building_id": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string" + }, + "is_outdoor": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "width": { + "type": "number" + }, + "x1": { + "type": "number" + }, + "x2": { + "type": "number" + }, + "y1": { + "type": "number" + }, + "y2": { + "type": "number" + } + }, + "required": [ + "barrier_free", + "building_id", + "createdAt", + "id", + "is_outdoor", + "name", + "storey", + "updatedAt", + "width", + "x1", + "x2", + "y1", + "y2" + ] + } + }, + "required": [ + "corridor" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Navigator", + "x-filc_type_name": "@unit Corridor", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/navigator/export": { + "get": { + "operationId": "getNavigatorExport", + "description": "Export every navigator row (buildings, classroom types, classrooms, corridors, lifts, stairs and translations) as a versioned JSON payload.", + "tags": [ + "Navigator" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "string", + "contentMediaType": "*/*" + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Navigator", + "x-filc_type_name": "@unit NavigatorTransfer", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/navigator/graph": { + "get": { + "operationId": "getNavigatorGraph", + "description": "The whole campus graph in the upstream wire format, without a session. Only rows the campus has placed on the map appear.", + "tags": [ + "Navigator" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "buildings": { + "type": "array", + "items": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mapped": { + "type": "boolean" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "description", + "id", + "mapped", + "name", + "x", + "y" + ] + } + }, + "classroom_types": { + "type": "array", + "items": { + "type": "object", + "properties": { + "colorhex": { + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}$" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "colorhex", + "id", + "name" + ] + } + }, + "classrooms": { + "type": "array", + "items": { + "type": "object", + "properties": { + "building_id": { + "type": "string", + "minLength": 1 + }, + "capacity": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mapped": { + "type": "boolean" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "rotation": { + "type": "number" + }, + "short": { + "type": "string" + }, + "size_x": { + "type": "number" + }, + "size_y": { + "type": "number" + }, + "size_z": { + "type": "number" + }, + "storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "type_id": { + "type": "string", + "minLength": 1 + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "building_id", + "capacity", + "description", + "id", + "mapped", + "name", + "rotation", + "short", + "size_x", + "size_y", + "size_z", + "storey", + "type_id", + "x", + "y" + ] + } + }, + "corridors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "barrier_free": { + "type": "boolean" + }, + "building_id": { + "type": "string", + "minLength": 1 + }, + "id": { + "type": "string" + }, + "is_outdoor": { + "type": "boolean" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "width": { + "type": "number" + }, + "x1": { + "type": "number" + }, + "x2": { + "type": "number" + }, + "y1": { + "type": "number" + }, + "y2": { + "type": "number" + } + }, + "required": [ + "barrier_free", + "building_id", + "id", + "is_outdoor", + "name", + "storey", + "width", + "x1", + "x2", + "y1", + "y2" + ] + } + }, + "lifts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "building_id": { + "type": "string", + "minLength": 1 + }, + "id": { + "type": "string" + }, + "max_storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "min_storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "name": { + "type": "string", + "minLength": 1 + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "building_id", + "id", + "max_storey", + "min_storey", + "name", + "x", + "y" + ] + } + }, + "stairs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "building_id": { + "type": "string", + "minLength": 1 + }, + "id": { + "type": "string" + }, + "max_storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "min_storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "name": { + "type": "string", + "minLength": 1 + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "rotation": { + "type": "number" + } + }, + "required": [ + "building_id", + "id", + "max_storey", + "min_storey", + "name", + "x", + "y", + "rotation" + ] + } + } + }, + "required": [ + "buildings", + "classroom_types", + "classrooms", + "corridors", + "lifts", + "stairs" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Navigator", + "x-filc_type_name": "@unit FullGraph" + } + }, + "/navigator/import": { + "post": { + "operationId": "postNavigatorImport", + "description": "Insert or update every collection in a navigator export payload by its key, inside one transaction. Rows absent from the payload are never deleted.", + "tags": [ + "Navigator" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "file": { + "type": "string", + "contentMediaType": "*/*" + } + }, + "required": [ + "file" + ] + } + }, + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "file": { + "type": "string", + "contentMediaType": "*/*" + } + }, + "required": [ + "file" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "buildings": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "classrooms": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "classroomTypes": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "corridors": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "lifts": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "stairs": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "translations": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "buildings", + "classrooms", + "classroomTypes", + "corridors", + "lifts", + "stairs", + "translations" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Navigator", + "x-filc_type_name": "@unit NavigatorImport", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/navigator/lifts": { + "post": { + "operationId": "postNavigatorLifts", + "description": "Create a lift", + "tags": [ + "Navigator" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "building_id": { + "type": "string", + "minLength": 1 + }, + "max_storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "min_storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "name": { + "type": "string", + "minLength": 1 + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "building_id", + "max_storey", + "min_storey", + "name", + "x", + "y" + ] + } + } + } + }, + "responses": { + "201": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "lift": { + "type": "object", + "properties": { + "building_id": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string" + }, + "max_storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "min_storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "name": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "building_id", + "createdAt", + "id", + "max_storey", + "min_storey", + "name", + "updatedAt", + "x", + "y" + ] + } + }, + "required": [ + "lift" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Navigator", + "x-filc_type_name": "@unit Lift", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + }, + "get": { + "operationId": "getNavigatorLifts", + "description": "List the lifts", + "tags": [ + "Navigator" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "lifts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "building_id": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string" + }, + "max_storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "min_storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "name": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "building_id", + "createdAt", + "id", + "max_storey", + "min_storey", + "name", + "updatedAt", + "x", + "y" + ] + } + } + }, + "required": [ + "lifts" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Navigator", + "x-filc_type_name": "@listof Lift" + } + }, + "/navigator/lifts/{id}": { + "delete": { + "operationId": "deleteNavigatorLiftsById", + "description": "Delete a lift", + "tags": [ + "Navigator" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "lift": { + "type": "object", + "properties": { + "building_id": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string" + }, + "max_storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "min_storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "name": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "building_id", + "createdAt", + "id", + "max_storey", + "min_storey", + "name", + "updatedAt", + "x", + "y" + ] + } + }, + "required": [ + "lift" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Navigator", + "x-filc_type_name": "@unit Lift", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + }, + "put": { + "operationId": "putNavigatorLiftsById", + "description": "Update a lift", + "tags": [ + "Navigator" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + } + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "building_id": { + "type": "string", + "minLength": 1 + }, + "max_storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "min_storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "name": { + "type": "string", + "minLength": 1 + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "lift": { + "type": "object", + "properties": { + "building_id": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string" + }, + "max_storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "min_storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "name": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "building_id", + "createdAt", + "id", + "max_storey", + "min_storey", + "name", + "updatedAt", + "x", + "y" + ] + } + }, + "required": [ + "lift" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Navigator", + "x-filc_type_name": "@unit Lift", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/navigator/stairs": { + "post": { + "operationId": "postNavigatorStairs", + "description": "Create a staircase", + "tags": [ + "Navigator" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "building_id": { + "type": "string", + "minLength": 1 + }, + "max_storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "min_storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "name": { + "type": "string", + "minLength": 1 + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "rotation": { + "type": "number" + } + }, + "required": [ + "building_id", + "max_storey", + "min_storey", + "name", + "x", + "y", + "rotation" + ] + } + } + } + }, + "responses": { + "201": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "stair": { + "type": "object", + "properties": { + "building_id": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string" + }, + "max_storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "min_storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "name": { + "type": "string" + }, + "rotation": { + "type": "number" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "building_id", + "createdAt", + "id", + "max_storey", + "min_storey", + "name", + "rotation", + "updatedAt", + "x", + "y" + ] + } + }, + "required": [ + "stair" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Navigator", + "x-filc_type_name": "@unit Stair", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + }, + "get": { + "operationId": "getNavigatorStairs", + "description": "List the staircases", + "tags": [ + "Navigator" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "stairs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "building_id": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string" + }, + "max_storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "min_storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "name": { + "type": "string" + }, + "rotation": { + "type": "number" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "building_id", + "createdAt", + "id", + "max_storey", + "min_storey", + "name", + "rotation", + "updatedAt", + "x", + "y" + ] + } + } + }, + "required": [ + "stairs" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Navigator", + "x-filc_type_name": "@listof Stair" + } + }, + "/navigator/stairs/{id}": { + "delete": { + "operationId": "deleteNavigatorStairsById", + "description": "Delete a staircase", + "tags": [ + "Navigator" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "stair": { + "type": "object", + "properties": { + "building_id": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string" + }, + "max_storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "min_storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "name": { + "type": "string" + }, + "rotation": { + "type": "number" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "building_id", + "createdAt", + "id", + "max_storey", + "min_storey", + "name", + "rotation", + "updatedAt", + "x", + "y" + ] + } + }, + "required": [ + "stair" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Navigator", + "x-filc_type_name": "@unit Stair", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + }, + "put": { + "operationId": "putNavigatorStairsById", + "description": "Update a staircase", + "tags": [ + "Navigator" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + } + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "building_id": { + "type": "string", + "minLength": 1 + }, + "max_storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "min_storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "name": { + "type": "string", + "minLength": 1 + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "rotation": { + "type": "number" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "stair": { + "type": "object", + "properties": { + "building_id": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string" + }, + "max_storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "min_storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "name": { + "type": "string" + }, + "rotation": { + "type": "number" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "building_id", + "createdAt", + "id", + "max_storey", + "min_storey", + "name", + "rotation", + "updatedAt", + "x", + "y" + ] + } + }, + "required": [ + "stair" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Navigator", + "x-filc_type_name": "@unit Stair", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/navigator/translations/available": { + "get": { + "operationId": "getNavigatorTranslationsAvailable", + "description": "List the languages the campus translations exist in", + "tags": [ + "Navigator" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "languages": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "languages" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Navigator", + "x-filc_type_name": "@listof Language" + } + }, + "/navigator/translations": { + "post": { + "operationId": "postNavigatorTranslations", + "description": "Create or replace one codename across every given language", + "tags": [ + "Navigator" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "text_key": { + "type": "string", + "minLength": 1 + }, + "translations": { + "type": "object", + "propertyNames": { + "type": "string", + "minLength": 2, + "maxLength": 10 + }, + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "text_key", + "translations" + ] + } + } + } + }, + "responses": { + "201": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "translations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "lang_key": { + "type": "string" + }, + "text": { + "type": "string" + }, + "text_key": { + "type": "string" + } + }, + "required": [ + "lang_key", + "text", + "text_key" + ] + } + } + }, + "required": [ + "translations" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Navigator", + "x-filc_type_name": "@listof Translation", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + }, + "get": { + "operationId": "getNavigatorTranslations", + "description": "List every translation row", + "tags": [ + "Navigator" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "translations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "lang_key": { + "type": "string" + }, + "text": { + "type": "string" + }, + "text_key": { + "type": "string" + } + }, + "required": [ + "lang_key", + "text", + "text_key" + ] + } + } + }, + "required": [ + "translations" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Navigator", + "x-filc_type_name": "@listof Translation", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/navigator/translations/{lang}/{key}": { + "delete": { + "operationId": "deleteNavigatorTranslationsByLangByKey", + "description": "Delete one codename in one language", + "tags": [ + "Navigator" + ], + "parameters": [ + { + "name": "lang", + "in": "path", + "required": true, + "schema": { + "type": "string", + "minLength": 2, + "maxLength": 10 + } + }, + { + "name": "key", + "in": "path", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "translation": { + "type": "object", + "properties": { + "lang_key": { + "type": "string" + }, + "text": { + "type": "string" + }, + "text_key": { + "type": "string" + } + }, + "required": [ + "lang_key", + "text", + "text_key" + ] + } + }, + "required": [ + "translation" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Navigator", + "x-filc_type_name": "@unit Translation", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + }, + "put": { + "operationId": "putNavigatorTranslationsByLangByKey", + "description": "Write the text of one codename in one language", + "tags": [ + "Navigator" + ], + "parameters": [ + { + "name": "lang", + "in": "path", + "required": true, + "schema": { + "type": "string", + "minLength": 2, + "maxLength": 10 + } + }, + { + "name": "key", + "in": "path", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "translation": { + "type": "object", + "properties": { + "lang_key": { + "type": "string" + }, + "text": { + "type": "string" + }, + "text_key": { + "type": "string" + } + }, + "required": [ + "lang_key", + "text", + "text_key" + ] + } + }, + "required": [ + "translation" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Navigator", + "x-filc_type_name": "@unit Translation", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/navigator/translations/lang": { + "get": { + "operationId": "getNavigatorTranslationsLang", + "description": "One language as a flat `text_key -> text` bundle, without a session", + "tags": [ + "Navigator" + ], + "parameters": [ + { + "name": "lang", + "in": "query", + "required": true, + "schema": { + "type": "string", + "minLength": 2, + "maxLength": 10 + }, + "allowEmptyValue": true, + "allowReserved": true + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Navigator", + "x-filc_type_name": "TranslationBundle" + } + }, + "/news/announcements": { + "post": { + "operationId": "postNewsAnnouncements", + "description": "Create a new announcement", + "tags": [ + "News / Announcements" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "cohortIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "content": { + "type": "array", + "items": { + "type": "object", + "properties": { + "content": {}, + "type": { + "type": "string" + } + }, + "required": [ + "type" + ] + } + }, + "highlighted": { + "type": "boolean" + }, + "kioskIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "kioskOnly": { + "type": "boolean" + }, + "title": { + "type": "string", + "minLength": 1 + }, + "validFrom": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "validUntil": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "content", + "validFrom", + "validUntil" + ] + } + } + } + }, + "responses": { + "201": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "authorId": { + "type": "string", + "format": "uuid" + }, + "content": {}, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "highlighted": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "imageByteSize": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "imageContentType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "imageKey": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "imageUpdatedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + { + "type": "null" + } + ] + }, + "kioskOnly": { + "type": "boolean" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "validFrom": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "validUntil": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "cohortIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "kioskIds": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "authorId", + "createdAt", + "highlighted", + "id", + "imageByteSize", + "imageContentType", + "imageKey", + "imageUpdatedAt", + "kioskOnly", + "title", + "updatedAt", + "validFrom", + "validUntil", + "cohortIds", + "kioskIds" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Announcement", + "x-filc_type_name": "@unit Announcement", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + }, + "get": { + "operationId": "getNewsAnnouncements", + "description": "List active announcements within date range, cohort-filtered by default; includeAll=true returns everything", + "tags": [ + "News / Announcements" + ], + "parameters": [ + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + }, + "allowEmptyValue": true, + "allowReserved": true + }, + { + "name": "offset", + "in": "query", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "default": 0 + }, + "allowEmptyValue": true, + "allowReserved": true + }, + { + "name": "includeAll", + "in": "query", + "schema": { + "type": "string", + "default": false + }, + "allowEmptyValue": true, + "allowReserved": true + }, + { + "name": "includeExpired", + "in": "query", + "schema": { + "type": "string", + "default": false + }, + "allowEmptyValue": true, + "allowReserved": true + }, + { + "name": "includeKioskOnly", + "in": "query", + "schema": { + "type": "string", + "default": false + }, + "allowEmptyValue": true, + "allowReserved": true + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "authorId": { + "type": "string", + "format": "uuid" + }, + "content": {}, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "highlighted": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "imageByteSize": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "imageContentType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "imageKey": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "imageUpdatedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + { + "type": "null" + } + ] + }, + "kioskOnly": { + "type": "boolean" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "validFrom": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "validUntil": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "cohortIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "kioskIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "author": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "image", + "name" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "authorId", + "createdAt", + "highlighted", + "id", + "imageByteSize", + "imageContentType", + "imageKey", + "imageUpdatedAt", + "kioskOnly", + "title", + "updatedAt", + "validFrom", + "validUntil", + "cohortIds", + "kioskIds" + ] + } + }, + "total": { + "type": "number" + } + }, + "required": [ + "data", + "total" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Announcement", + "x-filc_type_name": "@listof Announcement @field(.author, Author)", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/news/announcements/{id}": { + "delete": { + "operationId": "deleteNewsAnnouncementsById", + "description": "Delete an announcement", + "tags": [ + "News / Announcements" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Announcement", + "x-filc_type_name": "@nodata", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + }, + "get": { + "operationId": "getNewsAnnouncementsById", + "description": "Get a single announcement by ID", + "tags": [ + "News / Announcements" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "authorId": { + "type": "string", + "format": "uuid" + }, + "content": {}, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "highlighted": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "imageByteSize": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "imageContentType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "imageKey": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "imageUpdatedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + { + "type": "null" + } + ] + }, + "kioskOnly": { + "type": "boolean" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "validFrom": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "validUntil": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "cohortIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "kioskIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "author": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "image", + "name" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "authorId", + "createdAt", + "highlighted", + "id", + "imageByteSize", + "imageContentType", + "imageKey", + "imageUpdatedAt", + "kioskOnly", + "title", + "updatedAt", + "validFrom", + "validUntil", + "cohortIds", + "kioskIds" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Announcement", + "x-filc_type_name": "@unit Announcement @field(.author, Author)", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + }, + "patch": { + "operationId": "patchNewsAnnouncementsById", + "description": "Update an existing announcement", + "tags": [ + "News / Announcements" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "cohortIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "content": { + "type": "array", + "items": { + "type": "object", + "properties": { + "content": {}, + "type": { + "type": "string" + } + }, + "required": [ + "type" + ] + } + }, + "highlighted": { + "type": "boolean" + }, + "kioskIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "kioskOnly": { + "type": "boolean" + }, + "title": { + "type": "string", + "minLength": 1 + }, + "validFrom": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "validUntil": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "authorId": { + "type": "string", + "format": "uuid" + }, + "content": {}, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "highlighted": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "imageByteSize": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "imageContentType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "imageKey": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "imageUpdatedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + { + "type": "null" + } + ] + }, + "kioskOnly": { + "type": "boolean" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "validFrom": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "validUntil": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "cohortIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "kioskIds": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "authorId", + "createdAt", + "highlighted", + "id", + "imageByteSize", + "imageContentType", + "imageKey", + "imageUpdatedAt", + "kioskOnly", + "title", + "updatedAt", + "validFrom", + "validUntil", + "cohortIds", + "kioskIds" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Announcement", + "x-filc_type_name": "@unit Announcement", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/news/announcements/{id}/image": { + "delete": { + "operationId": "deleteNewsAnnouncementsByIdImage", + "description": "Delete the image attached to an announcement", + "tags": [ + "News / Announcements" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "authorId": { + "type": "string", + "format": "uuid" + }, + "content": {}, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "highlighted": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "imageByteSize": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "imageContentType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "imageKey": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "imageUpdatedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + { + "type": "null" + } + ] + }, + "kioskOnly": { + "type": "boolean" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "validFrom": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "validUntil": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "cohortIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "kioskIds": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "authorId", + "createdAt", + "highlighted", + "id", + "imageByteSize", + "imageContentType", + "imageKey", + "imageUpdatedAt", + "kioskOnly", + "title", + "updatedAt", + "validFrom", + "validUntil", + "cohortIds", + "kioskIds" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Announcement", + "x-filc_type_name": "@unit Announcement", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + }, + "post": { + "operationId": "postNewsAnnouncementsByIdImage", + "description": "Upload the image this announcement shows on the kiosk", + "tags": [ + "News / Announcements" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "file": { + "type": "string", + "contentMediaType": "*/*" + } + }, + "required": [ + "file" + ] + } + }, + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "file": { + "type": "string", + "contentMediaType": "*/*" + } + }, + "required": [ + "file" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "authorId": { + "type": "string", + "format": "uuid" + }, + "content": {}, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "highlighted": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "imageByteSize": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "imageContentType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "imageKey": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "imageUpdatedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + { + "type": "null" + } + ] + }, + "kioskOnly": { + "type": "boolean" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "validFrom": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "validUntil": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "cohortIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "kioskIds": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "authorId", + "createdAt", + "highlighted", + "id", + "imageByteSize", + "imageContentType", + "imageKey", + "imageUpdatedAt", + "kioskOnly", + "title", + "updatedAt", + "validFrom", + "validUntil", + "cohortIds", + "kioskIds" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Announcement", + "x-filc_type_name": "@unit Announcement", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/news/blogs": { + "post": { + "operationId": "postNewsBlogs", + "description": "Create a new blog post (defaults to draft)", + "tags": [ + "News / Blogs" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "content": { + "type": "array", + "items": { + "type": "object", + "properties": { + "content": {}, + "type": { + "type": "string" + } + }, + "required": [ + "type" + ] + } + }, + "slug": { + "type": "string", + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "status": { + "enum": [ + "draft", + "published" + ], + "type": "string", + "default": "draft" + }, + "title": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "content", + "title" + ] + } + } + } + }, + "responses": { + "201": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "authorId": { + "type": "string", + "format": "uuid" + }, + "content": {}, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "publishedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + { + "type": "null" + } + ] + }, + "slug": { + "type": "string" + }, + "status": { + "type": "string" + }, + "title": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "authorId", + "createdAt", + "id", + "publishedAt", + "slug", + "status", + "title", + "updatedAt" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "BlogPost", + "x-filc_type_name": "@unit BlogPost", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + }, + "get": { + "operationId": "getNewsBlogs", + "description": "List published blog posts (public, no auth required)", + "tags": [ + "News / Blogs" + ], + "parameters": [ + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + }, + "allowEmptyValue": true, + "allowReserved": true + }, + { + "name": "offset", + "in": "query", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "default": 0 + }, + "allowEmptyValue": true, + "allowReserved": true + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "authorId": { + "type": "string", + "format": "uuid" + }, + "content": {}, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "publishedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + { + "type": "null" + } + ] + }, + "slug": { + "type": "string" + }, + "status": { + "type": "string" + }, + "title": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "author": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "image", + "name" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "authorId", + "createdAt", + "id", + "publishedAt", + "slug", + "status", + "title", + "updatedAt" + ] + } + }, + "total": { + "type": "number" + } + }, + "required": [ + "data", + "total" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "BlogPost", + "x-filc_type_name": "@listof BlogPost @field(.author, Author)" + } + }, + "/news/blogs/{id}": { + "delete": { + "operationId": "deleteNewsBlogsById", + "description": "Delete a blog post", + "tags": [ + "News / Blogs" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "BlogPost", + "x-filc_type_name": "@nodata", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + }, + "patch": { + "operationId": "patchNewsBlogsById", + "description": "Update a blog post", + "tags": [ + "News / Blogs" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "content": { + "type": "array", + "items": { + "type": "object", + "properties": { + "content": {}, + "type": { + "type": "string" + } + }, + "required": [ + "type" + ] + } + }, + "slug": { + "type": "string", + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "title": { + "type": "string", + "minLength": 1 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "authorId": { + "type": "string", + "format": "uuid" + }, + "content": {}, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "publishedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + { + "type": "null" + } + ] + }, + "slug": { + "type": "string" + }, + "status": { + "type": "string" + }, + "title": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "authorId", + "createdAt", + "id", + "publishedAt", + "slug", + "status", + "title", + "updatedAt" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "BlogPost", + "x-filc_type_name": "@unit BlogPost", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/news/blogs/drafts": { + "get": { + "operationId": "getNewsBlogsDrafts", + "description": "List all blog posts including drafts (requires permission)", + "tags": [ + "News / Blogs" + ], + "parameters": [ + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + }, + "allowEmptyValue": true, + "allowReserved": true + }, + { + "name": "offset", + "in": "query", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "default": 0 + }, + "allowEmptyValue": true, + "allowReserved": true + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "authorId": { + "type": "string", + "format": "uuid" + }, + "content": {}, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "publishedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + { + "type": "null" + } + ] + }, + "slug": { + "type": "string" + }, + "status": { + "type": "string" + }, + "title": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "author": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "image", + "name" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "authorId", + "createdAt", + "id", + "publishedAt", + "slug", + "status", + "title", + "updatedAt" + ] + } + }, + "total": { + "type": "number" + } + }, + "required": [ + "data", + "total" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "BlogPost", + "x-filc_type_name": "@listof BlogPost @field(.author, Author)", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/news/blogs/{slug}": { + "get": { + "operationId": "getNewsBlogsBySlug", + "description": "Get a published blog post by slug (public, no auth required)", + "tags": [ + "News / Blogs" + ], + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "authorId": { + "type": "string", + "format": "uuid" + }, + "content": {}, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "publishedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + { + "type": "null" + } + ] + }, + "slug": { + "type": "string" + }, + "status": { + "type": "string" + }, + "title": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "author": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "image", + "name" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "authorId", + "createdAt", + "id", + "publishedAt", + "slug", + "status", + "title", + "updatedAt" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "BlogPost", + "x-filc_type_name": "@unit BlogPost @field(.author, Author)" + } + }, + "/news/blogs/id/{id}": { + "get": { + "operationId": "getNewsBlogsIdById", + "description": "Get any blog post by ID including drafts (requires permission)", + "tags": [ + "News / Blogs" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "authorId": { + "type": "string", + "format": "uuid" + }, + "content": {}, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "publishedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + { + "type": "null" + } + ] + }, + "slug": { + "type": "string" + }, + "status": { + "type": "string" + }, + "title": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "author": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "image", + "name" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "authorId", + "createdAt", + "id", + "publishedAt", + "slug", + "status", + "title", + "updatedAt" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "BlogPost", + "x-filc_type_name": "@unit BlogPost @field(.author, Author)", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/news/blogs/{id}/publish": { + "post": { + "operationId": "postNewsBlogsByIdPublish", + "description": "Publish a blog post (draft → published)", + "tags": [ + "News / Blogs" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "authorId": { + "type": "string", + "format": "uuid" + }, + "content": {}, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "publishedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + { + "type": "null" + } + ] + }, + "slug": { + "type": "string" + }, + "status": { + "type": "string" + }, + "title": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "authorId", + "createdAt", + "id", + "publishedAt", + "slug", + "status", + "title", + "updatedAt" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "BlogPost", + "x-filc_type_name": "@unit BlogPost", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/news/blogs/{id}/unpublish": { + "post": { + "operationId": "postNewsBlogsByIdUnpublish", + "description": "Unpublish a blog post (published → draft)", + "tags": [ + "News / Blogs" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "authorId": { + "type": "string", + "format": "uuid" + }, + "content": {}, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "publishedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + { + "type": "null" + } + ] + }, + "slug": { + "type": "string" + }, + "status": { + "type": "string" + }, + "title": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "authorId", + "createdAt", + "id", + "publishedAt", + "slug", + "status", + "title", + "updatedAt" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "BlogPost", + "x-filc_type_name": "@unit BlogPost", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/news/system-messages": { + "post": { + "operationId": "postNewsSystemMessages", + "description": "Create a new system message", + "tags": [ + "News / System Messages" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "cohortIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "content": { + "type": "array", + "items": { + "type": "object", + "properties": { + "content": {}, + "type": { + "type": "string" + } + }, + "required": [ + "type" + ] + } + }, + "title": { + "type": "string", + "minLength": 1 + }, + "validFrom": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "validUntil": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "content", + "title", + "validFrom", + "validUntil" + ] + } + } + } + }, + "responses": { + "201": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "authorId": { + "type": "string", + "format": "uuid" + }, + "content": {}, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "title": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "validFrom": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "validUntil": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "cohortIds": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "authorId", + "createdAt", + "id", + "title", + "updatedAt", + "validFrom", + "validUntil", + "cohortIds" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "SystemMessage", + "x-filc_type_name": "@unit SystemMessage", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + }, + "get": { + "operationId": "getNewsSystemMessages", + "description": "List active system messages within date range, filtered by user cohort", + "tags": [ + "News / System Messages" + ], + "parameters": [ + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + }, + "allowEmptyValue": true, + "allowReserved": true + }, + { + "name": "offset", + "in": "query", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "default": 0 + }, + "allowEmptyValue": true, + "allowReserved": true + }, + { + "name": "includeAll", + "in": "query", + "schema": { + "type": "string", + "default": false + }, + "allowEmptyValue": true, + "allowReserved": true + }, + { + "name": "includeExpired", + "in": "query", + "schema": { + "type": "string", + "default": false + }, + "allowEmptyValue": true, + "allowReserved": true + }, + { + "name": "includeKioskOnly", + "in": "query", + "schema": { + "type": "string", + "default": false + }, + "allowEmptyValue": true, + "allowReserved": true + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "authorId": { + "type": "string", + "format": "uuid" + }, + "content": {}, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "title": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "validFrom": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "validUntil": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "cohortIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "author": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "image", + "name" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "authorId", + "createdAt", + "id", + "title", + "updatedAt", + "validFrom", + "validUntil", + "cohortIds" + ] + } + }, + "total": { + "type": "number" + } + }, + "required": [ + "data", + "total" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "SystemMessage", + "x-filc_type_name": "@listof SystemMessage @field(.author, Author)", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/news/system-messages/{id}": { + "delete": { + "operationId": "deleteNewsSystemMessagesById", + "description": "Delete a system message", + "tags": [ + "News / System Messages" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "SystemMessage", + "x-filc_type_name": "@nodata", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + }, + "get": { + "operationId": "getNewsSystemMessagesById", + "description": "Get a single system message by ID", + "tags": [ + "News / System Messages" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "authorId": { + "type": "string", + "format": "uuid" + }, + "content": {}, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "title": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "validFrom": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "validUntil": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "cohortIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "author": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "image", + "name" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "authorId", + "createdAt", + "id", + "title", + "updatedAt", + "validFrom", + "validUntil", + "cohortIds" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "SystemMessage", + "x-filc_type_name": "@unit SystemMessage @field(.author, Author)", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + }, + "patch": { + "operationId": "patchNewsSystemMessagesById", + "description": "Update an existing system message", + "tags": [ + "News / System Messages" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "cohortIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "content": { + "type": "array", + "items": { + "type": "object", + "properties": { + "content": {}, + "type": { + "type": "string" + } + }, + "required": [ + "type" + ] + } + }, + "title": { + "type": "string", + "minLength": 1 + }, + "validFrom": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "validUntil": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "authorId": { + "type": "string", + "format": "uuid" + }, + "content": {}, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "title": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "validFrom": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "validUntil": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "cohortIds": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "authorId", + "createdAt", + "id", + "title", + "updatedAt", + "validFrom", + "validUntil", + "cohortIds" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "SystemMessage", + "x-filc_type_name": "@unit SystemMessage", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/ping": { + "get": { + "operationId": "getPing", + "description": "Health check endpoint that returns a pong response.", + "tags": [ + "Ping" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Ping", + "x-filc_type_name": "@unit PingResponse" + } + }, + "/ping/uptime": { + "get": { + "operationId": "getPingUptime", + "description": "Get the uptime.", + "tags": [ + "Ping" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "pretty": { + "type": "string" + }, + "uptime_ms": { + "type": "number" + } + }, + "required": [ + "pretty", + "uptime_ms" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Ping", + "x-filc_type_name": "@unit UptimeResponse" + } + }, + "/roles": { + "post": { + "operationId": "postRoles", + "description": "Create a new role", + "tags": [ + "Roles" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 50, + "pattern": "^[a-z0-9_-]+$" + }, + "permissions": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + } + }, + "required": [ + "name" + ] + } + } + } + }, + "responses": { + "201": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "can": { + "type": "array", + "items": { + "type": "string" + } + }, + "name": { + "type": "string" + } + }, + "required": [ + "can", + "name" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + } + }, + "get": { + "operationId": "getRoles", + "description": "List all roles with their permissions", + "tags": [ + "Roles" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "roles": { + "type": "array", + "items": { + "type": "object", + "properties": { + "can": { + "type": "array", + "items": { + "type": "string" + } + }, + "name": { + "type": "string" + } + }, + "required": [ + "can", + "name" + ] + } + } + }, + "required": [ + "roles" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + } + } + }, + "/roles/{name}": { + "delete": { + "operationId": "deleteRolesByName", + "description": "Delete a role", + "tags": [ + "Roles" + ], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "const": true + } + }, + "required": [ + "ok" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + } + }, + "patch": { + "operationId": "patchRolesByName", + "description": "Update permissions for a role", + "tags": [ + "Roles" + ], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "permissions": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "permissions" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "can": { + "type": "array", + "items": { + "type": "string" + } + }, + "name": { + "type": "string" + } + }, + "required": [ + "can", + "name" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + } + } + }, + "/roles/permissions": { + "get": { + "operationId": "getRolesPermissions", + "description": "List all known permissions registered by the application", + "tags": [ + "Roles" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "permissions": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "permissions" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + } + } + }, + "/timetable/timetables/cleanup-orphaned-cohorts": { + "post": { + "operationId": "postTimetableTimetablesCleanupOrphanedCohorts", + "description": "Delete all cohorts that are no longer linked to any timetable (orphaned) and all teachers that are not assigned to any lesson. Users referencing those cohorts will have their cohortId nullified. Teachers still referenced by cohorts, cohort groups, or substitutions are kept.", + "tags": [ + "Timetable" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "affectedUserCount": { + "type": "number" + }, + "deletedCohortIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "deletedTeacherIds": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "affectedUserCount", + "deletedCohortIds", + "deletedTeacherIds" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Timetable", + "x-filc_type_name": "@unit Timetable", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/timetable/timetables/{id}": { + "delete": { + "operationId": "deleteTimetableTimetablesById", + "description": "Delete a timetable and all its related data, including orphaned cohorts.", + "tags": [ + "Timetable" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Timetable", + "x-filc_type_name": "@unit Timetable", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + }, + "patch": { + "operationId": "patchTimetableTimetablesById", + "description": "Update a timetable validity dates.", + "tags": [ + "Timetable" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "validFrom": { + "type": "string" + }, + "validTo": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "validFrom": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "validTo": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "createdAt", + "id", + "name", + "updatedAt", + "validFrom", + "validTo" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Timetable", + "x-filc_type_name": "@unit Timetable", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/timetable/timetables/latestValid": { + "get": { + "operationId": "getTimetableTimetablesLatestValid", + "description": "Get the latest valid timetable.", + "tags": [ + "Timetable" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "validFrom": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "validTo": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "createdAt", + "id", + "name", + "updatedAt", + "validFrom", + "validTo" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Timetable", + "x-filc_type_name": "@unit Timetable", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/timetable/timetables": { + "get": { + "operationId": "getTimetableTimetables", + "description": "Get all timetables from the database.", + "tags": [ + "Timetable" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "validFrom": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "validTo": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "createdAt", + "id", + "name", + "updatedAt", + "validFrom", + "validTo" + ] + } + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Timetable", + "x-filc_type_name": "@listof Timetable", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/timetable/timetables/{id}/preview-delete": { + "get": { + "operationId": "getTimetableTimetablesByIdPreviewDelete", + "description": "Preview the impact of deleting a timetable. Orphaned cohorts will be deleted along with the timetable.", + "tags": [ + "Timetable" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "cohorts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "becomesOrphaned": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "becomesOrphaned", + "id", + "name" + ] + } + }, + "isCurrentTimetable": { + "type": "boolean" + }, + "targetTimetable": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + }, + { + "type": "null" + } + ] + }, + "totals": { + "type": "object", + "properties": { + "danglingUsersCleaned": { + "type": "number" + }, + "lessonsDeleted": { + "type": "number" + }, + "movedLessonsDeleted": { + "type": "number" + }, + "orphanedCohorts": { + "type": "number" + }, + "substitutionsDeleted": { + "type": "number" + }, + "survivingCohorts": { + "type": "number" + } + }, + "required": [ + "danglingUsersCleaned", + "lessonsDeleted", + "movedLessonsDeleted", + "orphanedCohorts", + "substitutionsDeleted", + "survivingCohorts" + ] + } + }, + "required": [ + "cohorts", + "isCurrentTimetable", + "targetTimetable", + "totals" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Timetable", + "x-filc_type_name": "@unit Timetable", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/timetable/timetables/valid": { + "get": { + "operationId": "getTimetableTimetablesValid", + "description": "Get all the latest valid timetables.", + "tags": [ + "Timetable" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "validFrom": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "validTo": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "createdAt", + "id", + "name", + "updatedAt", + "validFrom", + "validTo" + ] + } + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Timetable", + "x-filc_type_name": "@listof Timetable", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/timetable/import": { + "post": { + "operationId": "postTimetableImport", + "description": "Import a timetable from an Oman or aSc 2012 XML file.", + "tags": [ + "Timetable", + "Import" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "file": { + "type": "string", + "contentMediaType": "*/*" + }, + "name": { + "type": "string" + }, + "validFrom": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + }, + "validTo": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + } + }, + "required": [ + "file", + "name", + "validFrom" + ] + } + }, + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "file": { + "type": "string", + "contentMediaType": "*/*" + }, + "name": { + "type": "string" + }, + "validFrom": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + }, + "validTo": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + } + }, + "required": [ + "file", + "name", + "validFrom" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "const": true + } + }, + "required": [ + "ok" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + } + } + }, + "/timetable/classrooms/getAll": { + "get": { + "operationId": "getTimetableClassroomsGetAll", + "description": "Get all classrooms from the database.", + "tags": [ + "Classroom" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "building_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "capacity": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mapped": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "rotation": { + "type": "number" + }, + "short": { + "type": "string" + }, + "size_x": { + "type": "number" + }, + "size_y": { + "type": "number" + }, + "size_z": { + "type": "number" + }, + "storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "type_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "building_id", + "capacity", + "description", + "id", + "mapped", + "name", + "rotation", + "short", + "size_x", + "size_y", + "size_z", + "storey", + "type_id", + "x", + "y" + ] + } + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Classroom", + "x-filc_type_name": "@listof Classroom" + } + }, + "/timetable/classrooms/getAvailable": { + "get": { + "operationId": "getTimetableClassroomsGetAvailable", + "description": "Get classrooms that are free for a given date, day and period.", + "tags": [ + "Classroom" + ], + "parameters": [ + { + "name": "date", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "allowEmptyValue": true, + "allowReserved": true + }, + { + "name": "startingDay", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "allowEmptyValue": true, + "allowReserved": true + }, + { + "name": "startingPeriod", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "allowEmptyValue": true, + "allowReserved": true + }, + { + "name": "timetableId", + "in": "query", + "required": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "allowEmptyValue": true, + "allowReserved": true + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "building_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "capacity": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mapped": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "rotation": { + "type": "number" + }, + "short": { + "type": "string" + }, + "size_x": { + "type": "number" + }, + "size_y": { + "type": "number" + }, + "size_z": { + "type": "number" + }, + "storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "type_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "building_id", + "capacity", + "description", + "id", + "mapped", + "name", + "rotation", + "short", + "size_x", + "size_y", + "size_z", + "storey", + "type_id", + "x", + "y" + ] + } + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Classroom", + "x-filc_type_name": "@listof Classroom" + } + }, + "/timetable/cohorts/getAllForTimetable/{timetableId}": { + "get": { + "operationId": "getTimetableCohortsGetAllForTimetableByTimetableId", + "description": "Get cohorts for a given timetable from the database.", + "tags": [ + "Cohort" + ], + "parameters": [ + { + "name": "timetableId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "classroomIds": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + }, + "teacherId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "timetableId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "classroomIds", + "id", + "name", + "short", + "teacherId", + "timetableId" + ] + } + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Cohort", + "x-filc_type_name": "@listof Cohort", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/timetable/groups/getForCohort/{cohortId}": { + "get": { + "operationId": "getTimetableGroupsGetForCohortByCohortId", + "description": "Get the groups of a cohort, marking the current user selection.", + "tags": [ + "Group" + ], + "parameters": [ + { + "name": "cohortId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "divisionLabel": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "divisionTag": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "entireClass": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "selected": { + "type": "boolean" + }, + "studentCount": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "teacherId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "divisionLabel", + "divisionTag", + "entireClass", + "id", + "name", + "selected", + "studentCount", + "teacherId" + ] + } + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Group", + "x-filc_type_name": "@listof Group", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/timetable/groups/select": { + "post": { + "operationId": "postTimetableGroupsSelect", + "description": "Select the group the current user belongs to for a division. Replaces any previous membership in that division.", + "tags": [ + "Group" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "groupId": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "groupId" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "divisionTag": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "selectedGroupId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "divisionTag", + "selectedGroupId" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Group", + "x-filc_type_name": "@object Group @field(.selectedGroupId, string) @field(.divisionTag, string)", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/timetable/periods/getAll": { + "get": { + "operationId": "getTimetablePeriodsGetAll", + "description": "Get all period definitions used in a given timetable, sorted by period number.", + "tags": [ + "Period" + ], + "parameters": [ + { + "name": "timetableId", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + }, + "allowEmptyValue": true, + "allowReserved": true + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "endTime": { + "type": "string" + }, + "id": { + "type": "string" + }, + "period": { + "type": "number" + }, + "startTime": { + "type": "string" + } + }, + "required": [ + "endTime", + "id", + "period", + "startTime" + ] + } + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Period", + "x-filc_type_name": "@listof Period" + } + }, + "/timetable/subjects": { + "get": { + "operationId": "getTimetableSubjects", + "description": "Get all subjects from the database.", + "tags": [ + "Subject" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "createdAt", + "id", + "name", + "short", + "updatedAt" + ] + } + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Subject", + "x-filc_type_name": "@listof Subject", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/timetable/teachers/getAll": { + "get": { + "operationId": "getTimetableTeachersGetAll", + "description": "Get all teachers from the database.", + "tags": [ + "Teacher" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "firstName": { + "type": "string" + }, + "id": { + "type": "string" + }, + "lastName": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "firstName", + "id", + "lastName", + "short" + ] + } + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Teacher", + "x-filc_type_name": "@listof Teacher" + } + }, + "/timetable/teachers": { + "get": { + "operationId": "getTimetableTeachers", + "description": "List all teachers with their email and linked user.", + "tags": [ + "Teacher" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "email": { + "anyOf": [ + { + "type": "string", + "format": "email" + }, + { + "type": "null" + } + ] + }, + "firstName": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "lastName": { + "type": "string" + }, + "short": { + "type": "string" + }, + "user": { + "anyOf": [ + { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + } + }, + "required": [ + "email", + "id", + "name" + ] + }, + { + "type": "null" + } + ] + }, + "userId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "email", + "firstName", + "id", + "lastName", + "short", + "user", + "userId" + ] + } + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Teacher", + "x-filc_type_name": "@listof @unit TeacherListItem", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/timetable/teachers/me": { + "get": { + "operationId": "getTimetableTeachersMe", + "description": "Get the signed-in user's linked teacher, if any.", + "tags": [ + "Teacher" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "firstName": { + "type": "string" + }, + "id": { + "type": "string" + }, + "lastName": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "firstName", + "id", + "lastName", + "short" + ] + }, + { + "type": "null" + } + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Teacher", + "x-filc_type_name": "@unit Teacher", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/timetable/teachers/{id}": { + "patch": { + "operationId": "patchTimetableTeachersById", + "description": "Update a teacher email and/or linked user.", + "tags": [ + "Teacher" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "email": { + "anyOf": [ + { + "type": "string", + "format": "email" + }, + { + "type": "null" + } + ] + }, + "userId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "email": { + "anyOf": [ + { + "type": "string", + "format": "email" + }, + { + "type": "null" + } + ] + }, + "firstName": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "lastName": { + "type": "string" + }, + "short": { + "type": "string" + }, + "user": { + "anyOf": [ + { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + } + }, + "required": [ + "email", + "id", + "name" + ] + }, + { + "type": "null" + } + ] + }, + "userId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "email", + "firstName", + "id", + "lastName", + "short", + "user", + "userId" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Teacher", + "x-filc_type_name": "@unit TeacherListItem", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/timetable/lessons/getForCohort/{cohortId}": { + "get": { + "operationId": "getTimetableLessonsGetForCohortByCohortId", + "description": "Get lessons for a given cohort from the database.", + "tags": [ + "Lesson" + ], + "parameters": [ + { + "name": "cohortId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "timetableId", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + }, + "allowEmptyValue": true, + "allowReserved": true + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "classrooms": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + } + }, + "cohorts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + } + }, + "day": { + "type": "object", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "days": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "createdAt", + "days", + "id", + "name", + "short", + "updatedAt" + ] + }, + "groups": { + "type": "array", + "items": { + "type": "object", + "properties": { + "divisionTag": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "entireClass": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "divisionTag", + "entireClass", + "id", + "name" + ] + } + }, + "groupsIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "id": { + "type": "string" + }, + "period": { + "anyOf": [ + { + "type": "object", + "properties": { + "endTime": { + "type": "string" + }, + "id": { + "type": "string" + }, + "period": { + "type": "number" + }, + "startTime": { + "type": "string" + } + }, + "required": [ + "endTime", + "id", + "period", + "startTime" + ] + }, + { + "type": "null" + } + ] + }, + "periodsPerWeek": { + "type": "number" + }, + "subject": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + }, + { + "type": "null" + } + ] + }, + "teachers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + } + }, + "termDefinitionId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "weekDefinition": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + }, + "weeks": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "name", + "short", + "weeks" + ] + }, + { + "type": "null" + } + ] + }, + "weeksDefinitionId": { + "type": "string" + } + }, + "required": [ + "classrooms", + "cohorts", + "groups", + "groupsIds", + "id", + "period", + "periodsPerWeek", + "subject", + "teachers", + "termDefinitionId", + "weekDefinition", + "weeksDefinitionId" + ] + } + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Lesson", + "x-filc_type_name": "@listof EnrichedLesson @field(.classrooms, List) @field(.cohorts, List) @field(.day, DayDefinition) @field(.period, Period) @field(.subject, Subject) @field(.teachers, List)" + } + }, + "/timetable/lessons/getForId/{lessonId}": { + "get": { + "operationId": "getTimetableLessonsGetForIdByLessonId", + "description": "Get a lesson by its ID from the database.", + "tags": [ + "Lesson" + ], + "parameters": [ + { + "name": "lessonId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "classrooms": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + } + }, + "cohorts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + } + }, + "day": { + "type": "object", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "days": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "createdAt", + "days", + "id", + "name", + "short", + "updatedAt" + ] + }, + "groups": { + "type": "array", + "items": { + "type": "object", + "properties": { + "divisionTag": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "entireClass": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "divisionTag", + "entireClass", + "id", + "name" + ] + } + }, + "groupsIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "id": { + "type": "string" + }, + "period": { + "anyOf": [ + { + "type": "object", + "properties": { + "endTime": { + "type": "string" + }, + "id": { + "type": "string" + }, + "period": { + "type": "number" + }, + "startTime": { + "type": "string" + } + }, + "required": [ + "endTime", + "id", + "period", + "startTime" + ] + }, + { + "type": "null" + } + ] + }, + "periodsPerWeek": { + "type": "number" + }, + "subject": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + }, + { + "type": "null" + } + ] + }, + "teachers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + } + }, + "termDefinitionId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "weekDefinition": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + }, + "weeks": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "name", + "short", + "weeks" + ] + }, + { + "type": "null" + } + ] + }, + "weeksDefinitionId": { + "type": "string" + }, + "substitutionCohortName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "classrooms", + "cohorts", + "groups", + "groupsIds", + "id", + "period", + "periodsPerWeek", + "subject", + "teachers", + "termDefinitionId", + "weekDefinition", + "weeksDefinitionId", + "substitutionCohortName" + ] + }, + { + "type": "null" + } + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Lesson", + "x-filc_type_name": "@unit EnrichedLesson @field(.classrooms, List) @field(.day, DayDefinition) @field(.period, Period) @field(.subject, Subject) @field(.teachers, List)" + } + }, + "/timetable/lessons/getForRoom/{classroomId}": { + "get": { + "operationId": "getTimetableLessonsGetForRoomByClassroomId", + "description": "Get lessons for a given classroom from the database.", + "tags": [ + "Lesson" + ], + "parameters": [ + { + "name": "classroomId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "timetableId", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + }, + "allowEmptyValue": true, + "allowReserved": true + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "classrooms": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + } + }, + "cohorts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + } + }, + "day": { + "type": "object", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "days": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "createdAt", + "days", + "id", + "name", + "short", + "updatedAt" + ] + }, + "groups": { + "type": "array", + "items": { + "type": "object", + "properties": { + "divisionTag": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "entireClass": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "divisionTag", + "entireClass", + "id", + "name" + ] + } + }, + "groupsIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "id": { + "type": "string" + }, + "period": { + "anyOf": [ + { + "type": "object", + "properties": { + "endTime": { + "type": "string" + }, + "id": { + "type": "string" + }, + "period": { + "type": "number" + }, + "startTime": { + "type": "string" + } + }, + "required": [ + "endTime", + "id", + "period", + "startTime" + ] + }, + { + "type": "null" + } + ] + }, + "periodsPerWeek": { + "type": "number" + }, + "subject": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + }, + { + "type": "null" + } + ] + }, + "teachers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + } + }, + "termDefinitionId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "weekDefinition": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + }, + "weeks": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "name", + "short", + "weeks" + ] + }, + { + "type": "null" + } + ] + }, + "weeksDefinitionId": { + "type": "string" + } + }, + "required": [ + "classrooms", + "cohorts", + "groups", + "groupsIds", + "id", + "period", + "periodsPerWeek", + "subject", + "teachers", + "termDefinitionId", + "weekDefinition", + "weeksDefinitionId" + ] + } + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Lesson", + "x-filc_type_name": "@listof EnrichedLesson @field(.classrooms, List) @field(.cohorts, List) @field(.day, DayDefinition) @field(.period, Period) @field(.subject, Subject) @field(.teachers, List)" + } + }, + "/timetable/lessons/getForTeacher/{teacherId}": { + "get": { + "operationId": "getTimetableLessonsGetForTeacherByTeacherId", + "description": "Get lessons for a given teacher from the database.", + "tags": [ + "Lesson" + ], + "parameters": [ + { + "name": "teacherId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "timetableId", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + }, + "allowEmptyValue": true, + "allowReserved": true + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "classrooms": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + } + }, + "cohorts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + } + }, + "day": { + "type": "object", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "days": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "createdAt", + "days", + "id", + "name", + "short", + "updatedAt" + ] + }, + "groups": { + "type": "array", + "items": { + "type": "object", + "properties": { + "divisionTag": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "entireClass": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "divisionTag", + "entireClass", + "id", + "name" + ] + } + }, + "groupsIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "id": { + "type": "string" + }, + "period": { + "anyOf": [ + { + "type": "object", + "properties": { + "endTime": { + "type": "string" + }, + "id": { + "type": "string" + }, + "period": { + "type": "number" + }, + "startTime": { + "type": "string" + } + }, + "required": [ + "endTime", + "id", + "period", + "startTime" + ] + }, + { + "type": "null" + } + ] + }, + "periodsPerWeek": { + "type": "number" + }, + "subject": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + }, + { + "type": "null" + } + ] + }, + "teachers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + } + }, + "termDefinitionId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "weekDefinition": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + }, + "weeks": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "name", + "short", + "weeks" + ] + }, + { + "type": "null" + } + ] + }, + "weeksDefinitionId": { + "type": "string" + } + }, + "required": [ + "classrooms", + "cohorts", + "groups", + "groupsIds", + "id", + "period", + "periodsPerWeek", + "subject", + "teachers", + "termDefinitionId", + "weekDefinition", + "weeksDefinitionId" + ] + } + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Lesson", + "x-filc_type_name": "@listof EnrichedLesson @field(.classrooms, List) @field(.cohorts, List) @field(.day, DayDefinition) @field(.period, Period) @field(.subject, Subject) @field(.teachers, List)" + } + }, + "/timetable/lessons/getForTeachers": { + "post": { + "operationId": "postTimetableLessonsGetForTeachers", + "description": "Get lessons for multiple teachers in a single request.", + "tags": [ + "Lesson" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "teacherIds": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "format": "uuid" + } + } + }, + "required": [ + "teacherIds" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "lessons": { + "type": "array", + "items": { + "type": "object", + "properties": { + "classrooms": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + } + }, + "cohorts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + } + }, + "day": { + "type": "object", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "days": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "createdAt", + "days", + "id", + "name", + "short", + "updatedAt" + ] + }, + "groups": { + "type": "array", + "items": { + "type": "object", + "properties": { + "divisionTag": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "entireClass": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "divisionTag", + "entireClass", + "id", + "name" + ] + } + }, + "groupsIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "id": { + "type": "string" + }, + "period": { + "anyOf": [ + { + "type": "object", + "properties": { + "endTime": { + "type": "string" + }, + "id": { + "type": "string" + }, + "period": { + "type": "number" + }, + "startTime": { + "type": "string" + } + }, + "required": [ + "endTime", + "id", + "period", + "startTime" + ] + }, + { + "type": "null" + } + ] + }, + "periodsPerWeek": { + "type": "number" + }, + "subject": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + }, + { + "type": "null" + } + ] + }, + "teachers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + } + }, + "termDefinitionId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "weekDefinition": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + }, + "weeks": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "name", + "short", + "weeks" + ] + }, + { + "type": "null" + } + ] + }, + "weeksDefinitionId": { + "type": "string" + } + }, + "required": [ + "classrooms", + "cohorts", + "groups", + "groupsIds", + "id", + "period", + "periodsPerWeek", + "subject", + "teachers", + "termDefinitionId", + "weekDefinition", + "weeksDefinitionId" + ] + } + }, + "teacherId": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "lessons", + "teacherId" + ] + } + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Lesson", + "x-filc_type_name": "@listof TeacherLessonsBatchResult @field(.teacherId, String) @field(.lessons, List)" + } + }, + "/timetable/lessons/getSubstitutionCandidates": { + "post": { + "operationId": "postTimetableLessonsGetSubstitutionCandidates", + "description": "Get available lessons and substitute teacher candidates for substitution editing in one request.", + "tags": [ + "Lesson" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "date": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "missingTeacherId": { + "type": "string", + "format": "uuid" + }, + "selectedLessonIds": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "default": [] + }, + "teacherIds": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "format": "uuid" + } + } + }, + "required": [ + "date", + "missingTeacherId", + "teacherIds" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "availableLessons": { + "type": "array", + "items": { + "type": "object", + "properties": { + "classrooms": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + } + }, + "cohorts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + } + }, + "day": { + "type": "object", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "days": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "createdAt", + "days", + "id", + "name", + "short", + "updatedAt" + ] + }, + "groups": { + "type": "array", + "items": { + "type": "object", + "properties": { + "divisionTag": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "entireClass": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "divisionTag", + "entireClass", + "id", + "name" + ] + } + }, + "groupsIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "id": { + "type": "string" + }, + "period": { + "anyOf": [ + { + "type": "object", + "properties": { + "endTime": { + "type": "string" + }, + "id": { + "type": "string" + }, + "period": { + "type": "number" + }, + "startTime": { + "type": "string" + } + }, + "required": [ + "endTime", + "id", + "period", + "startTime" + ] + }, + { + "type": "null" + } + ] + }, + "periodsPerWeek": { + "type": "number" + }, + "subject": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + }, + { + "type": "null" + } + ] + }, + "teachers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + } + }, + "termDefinitionId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "weekDefinition": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + }, + "weeks": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "name", + "short", + "weeks" + ] + }, + { + "type": "null" + } + ] + }, + "weeksDefinitionId": { + "type": "string" + } + }, + "required": [ + "classrooms", + "cohorts", + "groups", + "groupsIds", + "id", + "period", + "periodsPerWeek", + "subject", + "teachers", + "termDefinitionId", + "weekDefinition", + "weeksDefinitionId" + ] + } + }, + "parallelLessons": { + "type": "array", + "items": { + "type": "object", + "properties": { + "classrooms": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + } + }, + "cohorts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + } + }, + "day": { + "type": "object", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "days": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "createdAt", + "days", + "id", + "name", + "short", + "updatedAt" + ] + }, + "groups": { + "type": "array", + "items": { + "type": "object", + "properties": { + "divisionTag": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "entireClass": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "divisionTag", + "entireClass", + "id", + "name" + ] + } + }, + "groupsIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "id": { + "type": "string" + }, + "period": { + "anyOf": [ + { + "type": "object", + "properties": { + "endTime": { + "type": "string" + }, + "id": { + "type": "string" + }, + "period": { + "type": "number" + }, + "startTime": { + "type": "string" + } + }, + "required": [ + "endTime", + "id", + "period", + "startTime" + ] + }, + { + "type": "null" + } + ] + }, + "periodsPerWeek": { + "type": "number" + }, + "subject": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + }, + { + "type": "null" + } + ] + }, + "teachers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + } + }, + "termDefinitionId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "weekDefinition": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + }, + "weeks": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "name", + "short", + "weeks" + ] + }, + { + "type": "null" + } + ] + }, + "weeksDefinitionId": { + "type": "string" + } + }, + "required": [ + "classrooms", + "cohorts", + "groups", + "groupsIds", + "id", + "period", + "periodsPerWeek", + "subject", + "teachers", + "termDefinitionId", + "weekDefinition", + "weeksDefinitionId" + ] + } + }, + "substituteCandidates": { + "type": "array", + "items": { + "type": "object", + "properties": { + "hasH1": { + "type": "boolean" + }, + "hasH2": { + "type": "boolean" + }, + "teacher": { + "type": "object", + "properties": { + "firstName": { + "type": "string" + }, + "id": { + "type": "string" + }, + "lastName": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "firstName", + "id", + "lastName", + "short" + ] + } + }, + "required": [ + "hasH1", + "hasH2", + "teacher" + ] + } + } + }, + "required": [ + "availableLessons", + "parallelLessons", + "substituteCandidates" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Lesson", + "x-filc_type_name": "@unit SubstitutionCandidatesResult @field(.availableLessons, List) @field(.parallelLessons, List) @field(.substituteCandidates, List)" + } + }, + "/timetable/substitutions": { + "post": { + "operationId": "postTimetableSubstitutions", + "description": "Create a new substitution", + "tags": [ + "Substitution" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "comment": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "date": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "lessonIds": { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + }, + "substituter": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "date", + "lessonIds" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "comment": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "date": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string" + }, + "substituter": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "comment", + "date", + "id", + "substituter" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Substitution", + "x-filc_type_name": "@unit Substitution", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + }, + "get": { + "operationId": "getTimetableSubstitutions", + "description": "Get all substitutions from the database.", + "tags": [ + "Substitution" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "lessons": { + "type": "array", + "items": { + "type": "object", + "properties": { + "classrooms": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + } + }, + "cohorts": { + "type": "array", + "items": { + "type": "string" + } + }, + "day": { + "type": "object", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "days": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "createdAt", + "days", + "id", + "name", + "short", + "updatedAt" + ] + }, + "id": { + "type": "string" + }, + "period": { + "anyOf": [ + { + "type": "object", + "properties": { + "endTime": { + "type": "string" + }, + "id": { + "type": "string" + }, + "period": { + "type": "number" + }, + "startTime": { + "type": "string" + } + }, + "required": [ + "endTime", + "id", + "period", + "startTime" + ] + }, + { + "type": "null" + } + ] + }, + "periodsPerWeek": { + "type": "number" + }, + "subject": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + }, + { + "type": "null" + } + ] + }, + "teachers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + } + }, + "termDefinitionId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "weeksDefinitionId": { + "type": "string" + } + }, + "required": [ + "classrooms", + "cohorts", + "id", + "period", + "periodsPerWeek", + "subject", + "teachers", + "termDefinitionId", + "weeksDefinitionId" + ] + } + }, + "substitution": { + "type": "object", + "properties": { + "comment": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "date": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string" + }, + "substituter": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "comment", + "date", + "id", + "substituter" + ] + }, + "teacher": { + "anyOf": [ + { + "type": "object", + "properties": { + "email": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "firstName": { + "type": "string" + }, + "gender": { + "anyOf": [ + { + "type": "string", + "maxLength": 1, + "pattern": "^[01]+$" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "lastName": { + "type": "string" + }, + "short": { + "type": "string" + }, + "userId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "email", + "firstName", + "gender", + "id", + "lastName", + "short", + "userId" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "lessons", + "substitution", + "teacher" + ] + } + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Substitution", + "x-filc_type_name": "@listof SubstitutionWithRelations @field(.substitution, Substitution) @field(.teacher, Teacher) @field(.lessons, List)", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/timetable/substitutions/{id}": { + "delete": { + "operationId": "deleteTimetableSubstitutionsById", + "description": "Delete a substitution", + "tags": [ + "Substitution" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Substitution", + "x-filc_type_name": "@nodata", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + }, + "put": { + "operationId": "putTimetableSubstitutionsById", + "description": "Update a substitution", + "tags": [ + "Substitution" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "comment": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "date": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "lessonIds": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "substituter": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "lessonIds" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "comment": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "date": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string" + }, + "substituter": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "comment", + "date", + "id", + "substituter" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Substitution", + "x-filc_type_name": "@unit Substitution", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/timetable/substitutions/export": { + "get": { + "operationId": "getTimetableSubstitutionsExport", + "description": "Export substitutions as a CSV file over an optional date range.", + "tags": [ + "Substitution" + ], + "parameters": [ + { + "name": "from", + "in": "query", + "schema": { + "type": "string", + "format": "date" + }, + "allowEmptyValue": true, + "allowReserved": true + }, + { + "name": "to", + "in": "query", + "schema": { + "type": "string", + "format": "date" + }, + "allowEmptyValue": true, + "allowReserved": true + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "string", + "contentMediaType": "*/*" + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Substitution", + "x-filc_type_name": "Export substitutions as CSV", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/timetable/substitutions/manual": { + "post": { + "operationId": "postTimetableSubstitutionsManual", + "description": "Create a substitution manually by specifying the teacher, lesson time, subject and cohort directly.", + "tags": [ + "Substitution" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "cohortId": { + "type": "string", + "format": "uuid" + }, + "comment": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "date": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "periodId": { + "type": "string", + "format": "uuid" + }, + "subjectId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + }, + "substituter": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + }, + "teacherId": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "cohortId", + "date", + "periodId", + "subjectId", + "substituter", + "teacherId" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "comment": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "date": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string" + }, + "substituter": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "comment", + "date", + "id", + "substituter" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Substitution", + "x-filc_type_name": "@unit Substitution", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/timetable/substitutions/relevant": { + "get": { + "operationId": "getTimetableSubstitutionsRelevant", + "description": "Get relevant substitutions from the database.", + "tags": [ + "Substitution" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "lessons": { + "type": "array", + "items": { + "type": "string" + } + }, + "substitution": { + "type": "object", + "properties": { + "comment": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "date": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string" + }, + "substituter": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "comment", + "date", + "id", + "substituter" + ] + }, + "teacher": { + "anyOf": [ + { + "type": "object", + "properties": { + "email": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "firstName": { + "type": "string" + }, + "gender": { + "anyOf": [ + { + "type": "string", + "maxLength": 1, + "pattern": "^[01]+$" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "lastName": { + "type": "string" + }, + "short": { + "type": "string" + }, + "userId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "email", + "firstName", + "gender", + "id", + "lastName", + "short", + "userId" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "lessons", + "substitution", + "teacher" + ] + } + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Substitution", + "x-filc_type_name": "@listof SubstitutionWithRelations @field(.substitution, Substitution) @field(.teacher, Teacher) @field(.lessons, List)", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/timetable/substitutions/cohort/{cohortId}": { + "get": { + "operationId": "getTimetableSubstitutionsCohortByCohortId", + "description": "Get relevant substitutions for a given cohort from the database.", + "tags": [ + "Substitution" + ], + "parameters": [ + { + "name": "cohortId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "cohortId": { + "type": "string" + }, + "substitutions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "lessons": { + "type": "array", + "items": { + "type": "string" + } + }, + "substitution": { + "type": "object", + "properties": { + "comment": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "date": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string" + }, + "substituter": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "comment", + "date", + "id", + "substituter" + ] + }, + "teacher": { + "anyOf": [ + { + "type": "object", + "properties": { + "email": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "firstName": { + "type": "string" + }, + "gender": { + "anyOf": [ + { + "type": "string", + "maxLength": 1, + "pattern": "^[01]+$" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "lastName": { + "type": "string" + }, + "short": { + "type": "string" + }, + "userId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "email", + "firstName", + "gender", + "id", + "lastName", + "short", + "userId" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "lessons", + "substitution", + "teacher" + ] + } + } + }, + "required": [ + "cohortId", + "substitutions" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Substitution", + "x-filc_type_name": "@unit SubstitutionsByCohort @field(.substitutions, List)", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/timetable/movedLessons": { + "post": { + "operationId": "postTimetableMovedLessons", + "description": "Create a moved lesson.", + "tags": [ + "Moved Lesson" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "comment": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "date": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "lessonIds": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "format": "uuid" + } + }, + "room": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "startingDay": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "startingPeriod": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "date", + "lessonIds" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "comment": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "date": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string" + }, + "room": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "startingDay": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "startingPeriod": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "comment", + "date", + "id", + "room", + "startingDay", + "startingPeriod" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "MovedLesson", + "x-filc_type_name": "@unit MovedLesson", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + }, + "get": { + "operationId": "getTimetableMovedLessons", + "description": "Get all moved lessons.", + "tags": [ + "Moved Lesson" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "classroom": { + "anyOf": [ + { + "type": "object", + "properties": { + "building_id": { + "type": "string" + }, + "capacity": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mapped": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "rotation": { + "type": "number" + }, + "short": { + "type": "string" + }, + "size_x": { + "type": "number" + }, + "size_y": { + "type": "number" + }, + "size_z": { + "type": "number" + }, + "storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "type_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "building_id", + "capacity", + "description", + "id", + "mapped", + "name", + "rotation", + "short", + "size_x", + "size_y", + "size_z", + "storey", + "type_id", + "x", + "y" + ] + }, + { + "type": "null" + } + ] + }, + "dayDefinition": { + "anyOf": [ + { + "type": "object", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "days": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "createdAt", + "days", + "id", + "name", + "short", + "updatedAt" + ] + }, + { + "type": "null" + } + ] + }, + "lessons": { + "type": "array", + "items": { + "type": "object", + "properties": { + "classrooms": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + } + }, + "cohorts": { + "type": "array", + "items": { + "type": "string" + } + }, + "day": { + "type": "object", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "days": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "createdAt", + "days", + "id", + "name", + "short", + "updatedAt" + ] + }, + "id": { + "type": "string" + }, + "period": { + "anyOf": [ + { + "type": "object", + "properties": { + "endTime": { + "type": "string" + }, + "id": { + "type": "string" + }, + "period": { + "type": "number" + }, + "startTime": { + "type": "string" + } + }, + "required": [ + "endTime", + "id", + "period", + "startTime" + ] + }, + { + "type": "null" + } + ] + }, + "periodsPerWeek": { + "type": "number" + }, + "subject": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + }, + { + "type": "null" + } + ] + }, + "teachers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + } + }, + "termDefinitionId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "weeksDefinitionId": { + "type": "string" + } + }, + "required": [ + "classrooms", + "cohorts", + "id", + "period", + "periodsPerWeek", + "subject", + "teachers", + "termDefinitionId", + "weeksDefinitionId" + ] + } + }, + "movedLesson": { + "type": "object", + "properties": { + "comment": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "date": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string" + }, + "room": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "startingDay": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "startingPeriod": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "comment", + "date", + "id", + "room", + "startingDay", + "startingPeriod" + ] + }, + "period": { + "anyOf": [ + { + "type": "object", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "endTime": { + "type": "string" + }, + "id": { + "type": "string" + }, + "period": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "startTime": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "createdAt", + "endTime", + "id", + "period", + "startTime", + "updatedAt" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "classroom", + "dayDefinition", + "lessons", + "movedLesson", + "period" + ] + } + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "MovedLesson", + "x-filc_type_name": "@listof MovedLessonWithRelations @field(.movedLesson, MovedLesson) @field(.classroom, Classroom) @field(.dayDefinition, DayDefinition) @field(.period, Period) @field(.lessons, List)" + } + }, + "/timetable/movedLessons/{id}": { + "delete": { + "operationId": "deleteTimetableMovedLessonsById", + "description": "Delete a moved lesson", + "tags": [ + "Moved Lesson" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "MovedLesson", + "x-filc_type_name": "@nodata", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + }, + "put": { + "operationId": "putTimetableMovedLessonsById", + "description": "Update a moved lesson.", + "tags": [ + "Moved Lesson" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "comment": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "date": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "lessonIds": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "format": "uuid" + } + }, + "room": { + "type": "string" + }, + "startingDay": { + "type": "string", + "format": "uuid" + }, + "startingPeriod": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "date", + "lessonIds", + "room", + "startingDay", + "startingPeriod" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "comment": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "date": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string" + }, + "room": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "startingDay": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "startingPeriod": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "comment", + "date", + "id", + "room", + "startingDay", + "startingPeriod" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "MovedLesson", + "x-filc_type_name": "@unit MovedLesson", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/timetable/movedLessons/export": { + "get": { + "operationId": "getTimetableMovedLessonsExport", + "description": "Export moved lessons as a CSV file over an optional date range.", + "tags": [ + "Moved Lesson" + ], + "parameters": [ + { + "name": "from", + "in": "query", + "schema": { + "type": "string", + "format": "date" + }, + "allowEmptyValue": true, + "allowReserved": true + }, + { + "name": "to", + "in": "query", + "schema": { + "type": "string", + "format": "date" + }, + "allowEmptyValue": true, + "allowReserved": true + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "string", + "contentMediaType": "*/*" + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "MovedLesson", + "x-filc_type_name": "Export moved lessons as CSV", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/timetable/movedLessons/cohort/{cohortId}": { + "get": { + "operationId": "getTimetableMovedLessonsCohortByCohortId", + "description": "Get all moved lessons for a cohort.", + "tags": [ + "Moved Lesson" + ], + "parameters": [ + { + "name": "cohortId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "classroom": { + "anyOf": [ + { + "type": "object", + "properties": { + "building_id": { + "type": "string" + }, + "capacity": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mapped": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "rotation": { + "type": "number" + }, + "short": { + "type": "string" + }, + "size_x": { + "type": "number" + }, + "size_y": { + "type": "number" + }, + "size_z": { + "type": "number" + }, + "storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "type_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "building_id", + "capacity", + "description", + "id", + "mapped", + "name", + "rotation", + "short", + "size_x", + "size_y", + "size_z", + "storey", + "type_id", + "x", + "y" + ] + }, + { + "type": "null" + } + ] + }, + "dayDefinition": { + "anyOf": [ + { + "type": "object", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "days": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "createdAt", + "days", + "id", + "name", + "short", + "updatedAt" + ] + }, + { + "type": "null" + } + ] + }, + "lessons": { + "type": "array", + "items": { + "type": "object", + "properties": { + "classrooms": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + } + }, + "cohorts": { + "type": "array", + "items": { + "type": "string" + } + }, + "day": { + "type": "object", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "days": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "createdAt", + "days", + "id", + "name", + "short", + "updatedAt" + ] + }, + "id": { + "type": "string" + }, + "period": { + "anyOf": [ + { + "type": "object", + "properties": { + "endTime": { + "type": "string" + }, + "id": { + "type": "string" + }, + "period": { + "type": "number" + }, + "startTime": { + "type": "string" + } + }, + "required": [ + "endTime", + "id", + "period", + "startTime" + ] + }, + { + "type": "null" + } + ] + }, + "periodsPerWeek": { + "type": "number" + }, + "subject": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + }, + { + "type": "null" + } + ] + }, + "teachers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + } + }, + "termDefinitionId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "weeksDefinitionId": { + "type": "string" + } + }, + "required": [ + "classrooms", + "cohorts", + "id", + "period", + "periodsPerWeek", + "subject", + "teachers", + "termDefinitionId", + "weeksDefinitionId" + ] + } + }, + "movedLesson": { + "type": "object", + "properties": { + "comment": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "date": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string" + }, + "room": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "startingDay": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "startingPeriod": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "comment", + "date", + "id", + "room", + "startingDay", + "startingPeriod" + ] + }, + "period": { + "anyOf": [ + { + "type": "object", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "endTime": { + "type": "string" + }, + "id": { + "type": "string" + }, + "period": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "startTime": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "createdAt", + "endTime", + "id", + "period", + "startTime", + "updatedAt" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "classroom", + "dayDefinition", + "lessons", + "movedLesson", + "period" + ] + } + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "MovedLesson", + "x-filc_type_name": "@listof MovedLessonWithRelations @field(.movedLesson, MovedLesson) @field(.classroom, Classroom) @field(.dayDefinition, DayDefinition) @field(.period, Period) @field(.lessons, List)" + } + }, + "/timetable/movedLessons/relevant": { + "get": { + "operationId": "getTimetableMovedLessonsRelevant", + "description": "Get relevant moved lessons for the active timetable.", + "tags": [ + "Moved Lesson" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "classroom": { + "anyOf": [ + { + "type": "object", + "properties": { + "building_id": { + "type": "string" + }, + "capacity": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mapped": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "rotation": { + "type": "number" + }, + "short": { + "type": "string" + }, + "size_x": { + "type": "number" + }, + "size_y": { + "type": "number" + }, + "size_z": { + "type": "number" + }, + "storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "type_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "building_id", + "capacity", + "description", + "id", + "mapped", + "name", + "rotation", + "short", + "size_x", + "size_y", + "size_z", + "storey", + "type_id", + "x", + "y" + ] + }, + { + "type": "null" + } + ] + }, + "dayDefinition": { + "anyOf": [ + { + "type": "object", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "days": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "createdAt", + "days", + "id", + "name", + "short", + "updatedAt" + ] + }, + { + "type": "null" + } + ] + }, + "lessons": { + "type": "array", + "items": { + "type": "object", + "properties": { + "classrooms": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + } + }, + "cohorts": { + "type": "array", + "items": { + "type": "string" + } + }, + "day": { + "type": "object", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "days": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "createdAt", + "days", + "id", + "name", + "short", + "updatedAt" + ] + }, + "id": { + "type": "string" + }, + "period": { + "anyOf": [ + { + "type": "object", + "properties": { + "endTime": { + "type": "string" + }, + "id": { + "type": "string" + }, + "period": { + "type": "number" + }, + "startTime": { + "type": "string" + } + }, + "required": [ + "endTime", + "id", + "period", + "startTime" + ] + }, + { + "type": "null" + } + ] + }, + "periodsPerWeek": { + "type": "number" + }, + "subject": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + }, + { + "type": "null" + } + ] + }, + "teachers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + } + }, + "termDefinitionId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "weeksDefinitionId": { + "type": "string" + } + }, + "required": [ + "classrooms", + "cohorts", + "id", + "period", + "periodsPerWeek", + "subject", + "teachers", + "termDefinitionId", + "weeksDefinitionId" + ] + } + }, + "movedLesson": { + "type": "object", + "properties": { + "comment": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "date": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string" + }, + "room": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "startingDay": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "startingPeriod": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "comment", + "date", + "id", + "room", + "startingDay", + "startingPeriod" + ] + }, + "period": { + "anyOf": [ + { + "type": "object", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "endTime": { + "type": "string" + }, + "id": { + "type": "string" + }, + "period": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "startTime": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "createdAt", + "endTime", + "id", + "period", + "startTime", + "updatedAt" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "classroom", + "dayDefinition", + "lessons", + "movedLesson", + "period" + ] + } + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "MovedLesson", + "x-filc_type_name": "@listof MovedLessonWithRelations @field(.movedLesson, MovedLesson) @field(.classroom, Classroom) @field(.dayDefinition, DayDefinition) @field(.period, Period) @field(.lessons, List)" + } + }, + "/timetable/movedLessons/cohort/{cohortId}/relevant": { + "get": { + "operationId": "getTimetableMovedLessonsCohortByCohortIdRelevant", + "description": "Get all relevant moved lessons for a given cohort.", + "tags": [ + "Moved Lesson" + ], + "parameters": [ + { + "name": "cohortId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "classroom": { + "anyOf": [ + { + "type": "object", + "properties": { + "building_id": { + "type": "string" + }, + "capacity": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mapped": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "rotation": { + "type": "number" + }, + "short": { + "type": "string" + }, + "size_x": { + "type": "number" + }, + "size_y": { + "type": "number" + }, + "size_z": { + "type": "number" + }, + "storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "type_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "building_id", + "capacity", + "description", + "id", + "mapped", + "name", + "rotation", + "short", + "size_x", + "size_y", + "size_z", + "storey", + "type_id", + "x", + "y" + ] + }, + { + "type": "null" + } + ] + }, + "dayDefinition": { + "anyOf": [ + { + "type": "object", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "days": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "createdAt", + "days", + "id", + "name", + "short", + "updatedAt" + ] + }, + { + "type": "null" + } + ] + }, + "lessons": { + "type": "array", + "items": { + "type": "object", + "properties": { + "classrooms": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + } + }, + "cohorts": { + "type": "array", + "items": { + "type": "string" + } + }, + "day": { + "type": "object", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "days": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "createdAt", + "days", + "id", + "name", + "short", + "updatedAt" + ] + }, + "id": { + "type": "string" + }, + "period": { + "anyOf": [ + { + "type": "object", + "properties": { + "endTime": { + "type": "string" + }, + "id": { + "type": "string" + }, + "period": { + "type": "number" + }, + "startTime": { + "type": "string" + } + }, + "required": [ + "endTime", + "id", + "period", + "startTime" + ] + }, + { + "type": "null" + } + ] + }, + "periodsPerWeek": { + "type": "number" + }, + "subject": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + }, + { + "type": "null" + } + ] + }, + "teachers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + } + }, + "termDefinitionId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "weeksDefinitionId": { + "type": "string" + } + }, + "required": [ + "classrooms", + "cohorts", + "id", + "period", + "periodsPerWeek", + "subject", + "teachers", + "termDefinitionId", + "weeksDefinitionId" + ] + } + }, + "movedLesson": { + "type": "object", + "properties": { + "comment": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "date": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string" + }, + "room": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "startingDay": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "startingPeriod": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "comment", + "date", + "id", + "room", + "startingDay", + "startingPeriod" + ] + }, + "period": { + "anyOf": [ + { + "type": "object", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "endTime": { + "type": "string" + }, + "id": { + "type": "string" + }, + "period": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "startTime": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "createdAt", + "endTime", + "id", + "period", + "startTime", + "updatedAt" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "classroom", + "dayDefinition", + "lessons", + "movedLesson", + "period" + ] + } + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "MovedLesson", + "x-filc_type_name": "@listof MovedLessonWithRelations @field(.movedLesson, MovedLesson) @field(.classroom, Classroom) @field(.dayDefinition, DayDefinition) @field(.period, Period) @field(.lessons, List)" + } + }, + "/users": { + "get": { + "operationId": "getUsers", + "description": "List users", + "tags": [ + "Users" + ], + "parameters": [ + { + "name": "limit", + "in": "query", + "schema": { + "type": "number", + "minimum": 1, + "maximum": 100, + "default": 20 + }, + "allowEmptyValue": true, + "allowReserved": true + }, + { + "name": "offset", + "in": "query", + "schema": { + "type": "number", + "minimum": 0, + "default": 0 + }, + "allowEmptyValue": true, + "allowReserved": true + }, + { + "name": "search", + "in": "query", + "schema": { + "type": "string" + }, + "allowEmptyValue": true, + "allowReserved": true + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "total": { + "type": "number" + }, + "users": { + "type": "array", + "items": { + "type": "object", + "properties": { + "cohortId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "email": { + "type": "string" + }, + "emailVerified": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + }, + "nickname": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "roles": { + "type": "array", + "items": { + "type": "string" + } + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "displayName": { + "type": "string" + }, + "permissions": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "cohortId", + "createdAt", + "email", + "emailVerified", + "id", + "image", + "name", + "nickname", + "roles", + "updatedAt", + "displayName", + "permissions" + ] + } + } + }, + "required": [ + "total", + "users" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Users", + "x-filc_type_name": "@unit UserListResponse @field(.users, List) @field(.total, Int)", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/users/me/api-keys": { + "post": { + "operationId": "postUsersMeApiKeys", + "description": "Create a new API key for the authenticated user. The raw key is only returned in this response.", + "tags": [ + "Users" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "expiresAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64 + } + }, + "required": [ + "name" + ] + } + } + } + }, + "responses": { + "201": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "apiKey": { + "type": "object", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "expiresAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string", + "format": "uuid" + }, + "lastUsedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + }, + "prefix": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "userId": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "createdAt", + "expiresAt", + "id", + "lastUsedAt", + "name", + "prefix", + "updatedAt", + "userId" + ] + }, + "rawKey": { + "type": "string" + } + }, + "required": [ + "apiKey", + "rawKey" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Users", + "x-filc_type_name": "@unit ApiKeyResponse @field(.apiKey, ApiKey) @field(.rawKey, String)", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + }, + "get": { + "operationId": "getUsersMeApiKeys", + "description": "List the API keys belonging to the authenticated user", + "tags": [ + "Users" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "apiKeys": { + "type": "array", + "items": { + "type": "object", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "expiresAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string", + "format": "uuid" + }, + "lastUsedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + }, + "prefix": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "userId": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "createdAt", + "expiresAt", + "id", + "lastUsedAt", + "name", + "prefix", + "updatedAt", + "userId" + ] + } + } + }, + "required": [ + "apiKeys" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Users", + "x-filc_type_name": "@unit ApiKeyListResponse @field(.apiKeys, List)", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/users/me/api-keys/{id}": { + "delete": { + "operationId": "deleteUsersMeApiKeysById", + "description": "Revoke (delete) an API key owned by the authenticated user", + "tags": [ + "Users" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "id" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Users", + "x-filc_type_name": "@nodata", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/users/{id}": { + "patch": { + "operationId": "patchUsersById", + "description": "Update user", + "tags": [ + "Users" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "cohortId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nickname": { + "type": "string" + }, + "roles": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "cohortId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "email": { + "type": "string" + }, + "emailVerified": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + }, + "nickname": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "roles": { + "type": "array", + "items": { + "type": "string" + } + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "displayName": { + "type": "string" + }, + "permissions": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "cohortId", + "createdAt", + "email", + "emailVerified", + "id", + "image", + "name", + "nickname", + "roles", + "updatedAt", + "displayName", + "permissions" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "Users", + "x-filc_type_name": "@unit User", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + } + } +} diff --git a/apps/chronos/package.json b/apps/chronos/package.json index e8637b04..8fff9e81 100644 --- a/apps/chronos/package.json +++ b/apps/chronos/package.json @@ -9,17 +9,21 @@ "db:seed": "bun run ./src/database/scripts/seed.ts", "db:import-navigator": "bun --bun src/database/scripts/import-navigator.ts", "db:reset": "bun run ./src/database/scripts/reset.ts", + "openapi:generate": "bun run scripts/generate-openapi.ts", "build": "bun run build.ts", "typecheck": "tsc" }, "dependencies": { "@better-auth/drizzle-adapter": "^1.7.2", "@filcdev/api": "workspace:*", + "@filcdev/auth": "workspace:*", "@filcdev/timetable-import": "workspace:*", - "@hono/standard-validator": "^0.4.0", - "@hono/swagger-ui": "^0.6.1", - "@hono/zod-validator": "^0.9.1", "@logtape/logtape": "^2.3.2", + "@orpc/contract": "catalog:", + "@orpc/experimental-ratelimit": "catalog:", + "@orpc/openapi": "catalog:", + "@orpc/server": "catalog:", + "@orpc/zod": "catalog:", "@sentry/bun": "^10.73.0", "better-auth": "catalog:", "cronbake": "^0.4.0", @@ -29,10 +33,6 @@ "fast-xml-parser": "^5.11.1", "firebase-admin": "^14.3.0", "handlebars": "^4.7.9", - "hono": "catalog:", - "hono-openapi": "^1.3.1", - "hono-rate-limiter": "^0.5.3", - "http-status-codes": "^2.3.0", "jose": "^6.2.10", "nodemailer": "^9.1.1", "string-ts": "^2.3.1", @@ -41,6 +41,7 @@ "devDependencies": { "@inquirer/prompts": "^8.7.1", "@logtape/pretty": "^2.3.2", + "@openapi-spec/downgrader": "^0.1.0", "@tsconfig/strictest": "^2.0.8", "@types/bun": "catalog:", "@types/cli-progress": "^3.11.6", @@ -50,10 +51,6 @@ "pg": "^8.23.0", "typescript": "catalog:" }, - "exports": { - "./types/auth": "./src/_types/auth.ts", - "./types/hc": "./src/_types/hc.ts" - }, "imports": { "#*": [ "./src/*.ts", diff --git a/apps/chronos/scripts/generate-openapi.ts b/apps/chronos/scripts/generate-openapi.ts new file mode 100644 index 00000000..574263b4 --- /dev/null +++ b/apps/chronos/scripts/generate-openapi.ts @@ -0,0 +1,34 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { appContract } from '@filcdev/api/contract'; +import { getLogger } from '@logtape/logtape'; +import { OpenAPIGenerator } from '@orpc/openapi'; +import { ZodToJsonSchemaConverter } from '@orpc/zod/zod4'; +import { configureLogger } from '#utils/logger'; +import { openApiSpecOptions } from '#utils/openapi-spec'; + +/** + * Writes `openapi/chronos-openapi.json` — the document `/api/doc/openapi.json` + * serves, byte for byte, from the same options the server uses + * (`#utils/openapi-spec`). Nothing is post-processed: external clients consume + * this file as generated. + * + * Run with `bun run openapi:generate` after any contract change, then refresh a + * consumer's copy (e.g. mergen's `openapi/filc-openapi.json`). + */ + +const generator = new OpenAPIGenerator({ + schemaConverters: [new ZodToJsonSchemaConverter()], +}); + +const spec = await generator.generate(appContract, openApiSpecOptions); + +await mkdir('openapi', { recursive: true }); +await writeFile( + 'openapi/chronos-openapi.json', + `${JSON.stringify(spec, null, 2)}\n` +); + +await configureLogger('chronos'); +getLogger(['chronos', 'openapi']).info( + `Wrote openapi/chronos-openapi.json (${Object.keys(spec.paths ?? {}).length} paths)` +); diff --git a/apps/chronos/src/_types/auth.ts b/apps/chronos/src/_types/auth.ts index cb6d1c15..516e1bf3 100644 --- a/apps/chronos/src/_types/auth.ts +++ b/apps/chronos/src/_types/auth.ts @@ -1,3 +1,12 @@ +import type { AuthSession } from '@filcdev/auth'; import type { auth } from '#utils/authentication'; -export type AuthType = typeof auth; +/** + * Compile-time proof that `@filcdev/auth` still describes this better-auth + * instance: the moment the server's session carries something the shared types + * do not promise, the apps would silently lose a field, and this stops + * compiling instead. + */ +export const authSessionMatchesPackage: AuthSession extends (typeof auth)['$Infer']['Session'] + ? true + : never = true; diff --git a/apps/chronos/src/_types/globals.ts b/apps/chronos/src/_types/globals.ts index a06dfd14..2b406187 100644 --- a/apps/chronos/src/_types/globals.ts +++ b/apps/chronos/src/_types/globals.ts @@ -1,29 +1,15 @@ -import type { ErrorCode } from '@filcdev/api/errors'; - import type { auth } from '#utils/authentication'; -export type Context = { - Variables: { - anonymousId: string | null; - user: typeof auth.$Infer.Session.user | null; - session: typeof auth.$Infer.Session.session | null; - }; -}; - -export type AuthenticatedContext = { - Variables: { - user: NonNullable; - session: NonNullable; - }; -}; - -export type SuccessResponse = [T] extends [undefined] - ? { success: true; data?: T } - : { success: true; data: T }; -export type ErrorResponse = { - cause?: unknown; - code: ErrorCode; - data?: unknown; - error: string; - success: false; +/** + * Everything the transport knows before a procedure runs, built once per + * request in `src/index.ts` and handed to both oRPC handlers. + */ +export type ChronosContext = { + anonymousId: string | null; + clientIp: string; + reqHeaders: Headers; + /** Injected by `ResponseHeadersPlugin`; a procedure can add to the response. */ + resHeaders?: Headers; + session: (typeof auth.$Infer.Session)['session'] | null; + user: (typeof auth.$Infer.Session)['user'] | null; }; diff --git a/apps/chronos/src/_types/hc.ts b/apps/chronos/src/_types/hc.ts deleted file mode 100644 index 9d74a80a..00000000 --- a/apps/chronos/src/_types/hc.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { bugReportRouter } from '#routes/bug-report/_router'; -import type { cohortRouter } from '#routes/cohort/_router'; -import type { dashboardRouter } from '#routes/dashboard/_router'; -import type { doorlockRouter } from '#routes/doorlock/_router'; -import type { kioskRouter } from '#routes/kiosk/_router'; -import type { navigatorRouter } from '#routes/navigator/_router'; -import type { newsRouter } from '#routes/news/_router'; -import type { notificationsRouter } from '#routes/notifications/_router'; -import type { pingRouter } from '#routes/ping/_router'; -import type { rolesRouter } from '#routes/roles/_router'; -import type { timetableRouter } from '#routes/timetable/_router'; -import type { usersRouter } from '#routes/users/_router'; - -export type BugReportRouter = typeof bugReportRouter; -export type CohortRouter = typeof cohortRouter; -export type DashboardRouter = typeof dashboardRouter; -export type DoorlockRouter = typeof doorlockRouter; -export type KioskRouter = typeof kioskRouter; -export type NavigatorRouter = typeof navigatorRouter; -export type NewsRouter = typeof newsRouter; -export type NotificationsRouter = typeof notificationsRouter; -export type PingRouter = typeof pingRouter; -export type RolesRouter = typeof rolesRouter; -export type TimetableRouter = typeof timetableRouter; -export type UsersRouter = typeof usersRouter; diff --git a/apps/chronos/src/index.ts b/apps/chronos/src/index.ts index 20f7e14f..f31cbcf5 100644 --- a/apps/chronos/src/index.ts +++ b/apps/chronos/src/index.ts @@ -1,40 +1,34 @@ import process from 'node:process'; -import { swaggerUI } from '@hono/swagger-ui'; +import { apiErrors } from '@filcdev/api/errors'; import { getLogger } from '@logtape/logtape'; -import type { Session } from 'better-auth'; -import { Hono } from 'hono'; -import { getConnInfo, websocket } from 'hono/bun'; -import { showRoutes } from 'hono/dev'; -import { HTTPException } from 'hono/http-exception'; -import { openAPIRouteHandler } from 'hono-openapi'; -import { rateLimiter } from 'hono-rate-limiter'; -import { StatusCodes } from 'http-status-codes'; -import type { Context, ErrorResponse } from '#_types/globals'; +import { OpenAPIHandler } from '@orpc/openapi/fetch'; +import { OpenAPIReferencePlugin } from '@orpc/openapi/plugins'; +import { ORPCError } from '@orpc/server'; +import type { + FetchHandlerOptions, + RPCHandlerOptions, +} from '@orpc/server/fetch'; +import { RPCHandler } from '@orpc/server/fetch'; +import { CORSPlugin, ResponseHeadersPlugin } from '@orpc/server/plugins'; +import { ZodToJsonSchemaConverter } from '@orpc/zod/zod4'; +import type { ChronosContext } from '#_types/globals'; import { prepareDb } from '#database'; -import { anonymousIdMiddleware } from '#middleware/anonymous-id'; -import { authenticationMiddleware } from '#middleware/auth'; -import { corsMiddleware, securityMiddleware } from '#middleware/security'; -import { timingMiddleware } from '#middleware/timing'; -import { bugReportRouter } from '#routes/bug-report/_router'; -import { cohortRouter } from '#routes/cohort/_router'; -import { dashboardRouter } from '#routes/dashboard/_router'; -import { doorlockRouter } from '#routes/doorlock/_router'; -import { healthRouter } from '#routes/health/_router'; -import { kioskRouter } from '#routes/kiosk/_router'; -import { navigatorRouter } from '#routes/navigator/_router'; -import { newsRouter } from '#routes/news/_router'; -import { notificationsRouter } from '#routes/notifications/_router'; -import { pingRouter } from '#routes/ping/_router'; -import { rolesRouter } from '#routes/roles/_router'; -import { timetableRouter } from '#routes/timetable/_router'; -import { usersRouter } from '#routes/users/_router'; -import { authRouter } from '#utils/authentication'; +import { resolveCaller } from '#middleware/auth'; +import { appRouter } from '#router'; +import { + type DeviceSocketData, + DOORLOCK_SOCKET_PATH, + handlers as deviceSocketHandlers, + upgrade as upgradeDeviceSocket, +} from '#routes/doorlock/device-socket'; +import { handleUnsubscribe } from '#routes/notifications/unsubscribe-html'; +import { auth } from '#utils/authentication'; import { initializeRBAC } from '#utils/authorization'; import { setupCronJobs } from '#utils/cron'; import { env } from '#utils/environment'; -import { errorCodeOf } from '#utils/http'; import { configureLogger } from '#utils/logger'; import { initializeNotificationEngine } from '#utils/notifications/initialize'; +import { openApiSpecOptions } from '#utils/openapi-spec'; import { initSentry } from '#utils/telemetry'; await configureLogger('chronos'); @@ -46,145 +40,210 @@ if (env.mode === 'development') { logger.warn('Running in development mode, do not use in production!'); } -export const api = new Hono(); - await prepareDb(); await initializeRBAC(); setupCronJobs(); initializeNotificationEngine(); -api.use('*', corsMiddleware); -api.use('*', authenticationMiddleware); -api.use('*', anonymousIdMiddleware); -api.use('*', securityMiddleware); -api.use('*', timingMiddleware); - -api.use( - '*', - rateLimiter({ - handler: async (c, _, options) => { - // Log the rate limit event - logger.debug(`Rate limit exceeded for ${await options.keyGenerator(c)}`); - - // Return custom response - return c.json( - { - code: 'RATE_LIMITED', - data: { - retryAfter: c.res.headers.get('Retry-After'), - }, - error: 'Too many requests', - success: false, - }, - 429 - ); - }, - keyGenerator: (c) => { - const session = c.get('session' as never) as Session | null; - const anonymousId = c.get('anonymousId' as never) as string | null; - const connInfo = getConnInfo(c); - const realIp = env.realIpHeader - ? c.req.header(env.realIpHeader) - : undefined; - const clientId = session?.id ?? anonymousId ?? 'unknown'; - const ip = realIp ?? connInfo.remote.address ?? 'unknown'; - return `${clientId}|${ip}`; - }, - limit: env.rateLimitMax, - windowMs: env.rateLimitWindowMs, - }) -); - -api.route('/auth', authRouter); -api.route('/ping', pingRouter); -api.route('/health', healthRouter); -api.route('/timetable', timetableRouter); -api.route('/cohort', cohortRouter); -api.route('/dashboard', dashboardRouter); -api.route('/doorlock', doorlockRouter); -api.route('/kiosk', kioskRouter); -api.route('/navigator', navigatorRouter); -api.route('/users', usersRouter); -api.route('/roles', rolesRouter); -api.route('/news', newsRouter); -api.route('/notifications', notificationsRouter); -api.route('/bug-report', bugReportRouter); - -api.onError((err, c) => { - if (err instanceof HTTPException) { - return ( - err.res ?? - c.json( - { - cause: env.mode === 'production' ? undefined : err.cause, - code: errorCodeOf(err), - error: err.message, - success: false, - }, - err.status - ) - ); +/** + * The header set the pre-oRPC `secureHeaders` middleware produced, applied to + * every response the server emits. `script-src-elem`/`style-src-elem` keep + * allowing the CDN bundles Swagger UI loads, and `Cross-Origin-Resource-Policy: + * cross-origin` keeps announcement images embeddable by the kiosk build, which + * is served from a host of its own. + */ +const SECURITY_HEADERS: Readonly> = { + 'Content-Security-Policy': + "base-uri 'self'; child-src 'self'; connect-src 'self'; default-src 'self'; font-src 'self' https: data:; form-action 'self'; frame-ancestors 'self'; frame-src 'self'; img-src 'self' data:; manifest-src 'self'; media-src 'self'; object-src 'none'; report-to endpoint-1; sandbox allow-same-origin allow-scripts; script-src 'self'; script-src-attr 'none'; script-src-elem 'self' https: 'unsafe-inline'; style-src 'self' https: 'unsafe-inline'; style-src-attr 'none'; style-src-elem 'self' https: 'unsafe-inline'; upgrade-insecure-requests; worker-src 'self'", + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Cross-Origin-Resource-Policy': 'cross-origin', + 'Origin-Agent-Cluster': '?1', + 'Referrer-Policy': 'no-referrer', + 'Strict-Transport-Security': 'max-age=15552000; includeSubDomains', + 'X-Content-Type-Options': 'nosniff', + 'X-DNS-Prefetch-Control': 'off', + 'X-Download-Options': 'noopen', + 'X-Frame-Options': 'SAMEORIGIN', + 'X-Permitted-Cross-Domain-Policies': 'none', + 'X-XSS-Protection': '0', +}; + +const withSecurityHeaders = (response: Response): Response => { + const headers = new Headers(response.headers); + for (const [name, value] of Object.entries(SECURITY_HEADERS)) { + headers.set(name, value); } - logger.error('UNCAUGHT API error occurred:', { - message: err.message, - stack: err.stack, + return new Response(response.body, { + headers, + status: response.status, + statusText: response.statusText, }); +}; - const isProduction = env.mode === 'production'; - const cause = err instanceof Error ? err.cause : undefined; - - return c.json( - { - cause: isProduction ? undefined : cause, - code: 'INTERNAL', - error: isProduction ? 'Internal Server Error' : err.message, - success: false, - }, - StatusCodes.INTERNAL_SERVER_ERROR - ); -}); +const isDevelopment = env.mode === 'development'; + +type ClientInterceptor = NonNullable< + RPCHandlerOptions['clientInterceptors'] +>[number]; + +/** + * Turns anything a handler throws that is not already an `ORPCError` into a + * logged `INTERNAL` error: the message is the raw one in development and the + * generic one in production, so internal failures never leak their details. + */ +const errorBoundaryInterceptor: ClientInterceptor = async ({ next }) => { + try { + return await next(); + } catch (error) { + if (error instanceof ORPCError) { + throw error; + } + + const err = error instanceof Error ? error : new Error(String(error)); + logger.error('UNCAUGHT API error occurred:', { + message: err.message, + stack: err.stack, + }); + + throw new ORPCError('INTERNAL', { + cause: err, + message: isDevelopment ? err.message : 'Internal Server Error', + status: apiErrors.INTERNAL.status, + }); + } +}; + +type AdapterInterceptor = NonNullable< + FetchHandlerOptions['adapterInterceptors'] +>[number]; + +/** Request log: trace line in development, structured line otherwise. */ +const timingInterceptor: AdapterInterceptor = async (options) => { + const start = Date.now(); + const result = await options.next(); + const ms = Date.now() - start; + + const status = result.response?.status ?? 404; + const { method } = options.request; + const { url } = options.request; + const { + context: { user, clientIp }, + } = options; + + if (isDevelopment) { + logger.trace(`${method} ${url} - ${ms}ms`, { + duration: ms, + method, + status, + url, + user: user ? { email: user.email, id: user.id } : null, + }); + + return result; + } -api.get( - '/doc/openapi.json', - openAPIRouteHandler(api, { - documentation: { - info: { - description: 'API for consumption by the Filc app family.', - title: 'Chronos backend API', - version: '0.0.1', - }, - servers: [ - env.mode === 'development' - ? { description: 'Local Server', url: 'http://localhost:3000/api' } - : { description: 'chronos', url: 'https://filc.petrik.hu/api' }, - ], - }, - }) -); -api.get('/doc/swagger', swaggerUI({ url: '/api/doc/openapi.json' })); - -const app = new Hono(); -app.route('/api', api); - -app.onError((err, c) => { - logger.error('Unhandled error occurred:', { - message: err.message, - stack: err.stack, + logger.info('Received request', { + duration: ms, + ip: clientIp, + method, + status, + ua: options.request.headers.get('user-agent') ?? 'unknown', + url, + user: user ? { email: user.email, id: user.id } : null, }); - return c.redirect('/error'); + + return result; +}; + +const corsPlugins = () => [ + new CORSPlugin({ + allowHeaders: ['Content-Type', 'Authorization'], + allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], + origin: isDevelopment + ? (origin) => origin + : [env.baseUrl, ...(env.trustedOrigins ?? [])], + }), + // Lets a procedure set response headers (`context.resHeaders`), which the + // announcement image uses for its long-lived Cache-Control. + new ResponseHeadersPlugin(), +]; + +const openApiPlugin = new OpenAPIReferencePlugin({ + docsPath: '/doc/swagger', + docsProvider: 'swagger', + docsTitle: 'Chronos backend API', + schemaConverters: [new ZodToJsonSchemaConverter()], + specGenerateOptions: openApiSpecOptions, + specPath: '/doc/openapi.json', }); -export const server = Bun.serve({ - fetch: app.fetch, +export const rpcHandler = new RPCHandler(appRouter, { + adapterInterceptors: [timingInterceptor], + clientInterceptors: [errorBoundaryInterceptor], + plugins: corsPlugins(), +}); + +export const openApiHandler = new OpenAPIHandler(appRouter, { + adapterInterceptors: [timingInterceptor], + clientInterceptors: [errorBoundaryInterceptor], + plugins: [...corsPlugins(), openApiPlugin], +}); + +/** + * Everything the transport knows before a procedure runs. Built per request, + * once, only for the requests that reach an oRPC handler. + */ +const buildContext = async ( + request: Request, + srv: Bun.Server +): Promise => ({ + ...(await resolveCaller(request.headers)), + clientIp: env.realIpHeader + ? (request.headers.get(env.realIpHeader) ?? '') + : (srv.requestIP(request)?.address ?? 'unknown'), + reqHeaders: request.headers, +}); + +export const server = Bun.serve({ + fetch: async (request, srv) => { + const url = new URL(request.url); + + // The aegis firmware speaks a hand-rolled JSON protocol over this socket, + // not oRPC, so it is handled before either handler. + if (url.pathname === DOORLOCK_SOCKET_PATH) { + return upgradeDeviceSocket(request, srv); + } + + // better-auth owns /api/auth/*; it answers its own JSON and HTML. + if (url.pathname.startsWith('/api/auth/')) { + return withSecurityHeaders(await auth.handler(request)); + } + + // The unsubscribe pages are HTML forms and links from emails: no envelope, + // no oRPC. + if (url.pathname === '/api/notifications/unsubscribe') { + return withSecurityHeaders(await handleUnsubscribe(request)); + } + + const context = await buildContext(request, srv); + const { matched, response } = url.pathname.startsWith('/api/rpc/') + ? await rpcHandler.handle(request, { context, prefix: '/api/rpc' }) + : await openApiHandler.handle(request, { context, prefix: '/api' }); + + return withSecurityHeaders( + matched && response + ? response + : new Response('Not found', { status: 404 }) + ); + }, port: env.port, - websocket, + websocket: deviceSocketHandlers, }); logger.info(`chronos listening on http://localhost:${env.port}`); if (env.logLevel === 'trace') { - logger.info('Log level set to TRACE, verbose route listing enabled'); - showRoutes(app, { verbose: true }); + logger.info('Log level set to TRACE, verbose request logging enabled'); } const handleShutdown = async () => { @@ -196,6 +255,3 @@ const handleShutdown = async () => { process.on('SIGINT', handleShutdown); process.on('SIGTERM', handleShutdown); - -export type ApiType = typeof api; -export type AppType = typeof app; diff --git a/apps/chronos/src/middleware/anonymous-id.ts b/apps/chronos/src/middleware/anonymous-id.ts deleted file mode 100644 index c5f0bcab..00000000 --- a/apps/chronos/src/middleware/anonymous-id.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { createHmac, timingSafeEqual } from 'node:crypto'; -import { createMiddleware } from 'hono/factory'; -import type { Context } from '#_types/globals'; -import { env } from '#utils/environment'; - -const SIGNATURE_LENGTH = 64; // SHA-256 hex digest - -function sign(id: string): string { - return createHmac('sha256', env.authSecret).update(id).digest('hex'); -} - -function verify(cookieValue: string): string | null { - const dotIndex = cookieValue.lastIndexOf('.'); - if (dotIndex === -1) { - return null; - } - - const id = cookieValue.slice(0, dotIndex); - const sig = cookieValue.slice(dotIndex + 1); - if (sig.length !== SIGNATURE_LENGTH) { - return null; - } - - const expected = sign(id); - const sigBuf = Buffer.from(sig, 'hex'); - const expectedBuf = Buffer.from(expected, 'hex'); - if (sigBuf.length !== expectedBuf.length) { - return null; - } - if (!timingSafeEqual(sigBuf, expectedBuf)) { - return null; - } - - return id; -} - -export const anonymousIdMiddleware = createMiddleware((c, next) => { - const session = c.get('session'); - - // Authenticated users don't need an anonymous ID - if (session) { - return next(); - } - - const cookieName = env.rateLimitCookieName; - const rawCookie = c.req - .header('Cookie') - ?.split(';') - .map((cookie) => cookie.trim()) - .find((cookie) => cookie.startsWith(`${cookieName}=`)) - ?.split('=') - .slice(1) - .join('='); // handle values containing '=' - - if (rawCookie) { - const id = verify(rawCookie); - if (id) { - c.set('anonymousId', id); - return next(); - } - } - - // No valid cookie — do not mint a new UUID; fall back to IP-only rate limiting - return next(); -}); diff --git a/apps/chronos/src/middleware/auth.ts b/apps/chronos/src/middleware/auth.ts index c020b3fb..68c36be5 100644 --- a/apps/chronos/src/middleware/auth.ts +++ b/apps/chronos/src/middleware/auth.ts @@ -1,95 +1,131 @@ -import { createMiddleware } from 'hono/factory'; -import { HTTPException } from 'hono/http-exception'; -import { StatusCodes } from 'http-status-codes'; -import type { AuthenticatedContext, Context } from '#_types/globals'; +import { createHmac, timingSafeEqual } from 'node:crypto'; +import type { ChronosContext } from '#_types/globals'; +import { base } from '#orpc'; import { extractApiKey, validateApiKey } from '#utils/api-keys'; import { auth } from '#utils/authentication'; import { rbac, userHasPermission } from '#utils/authorization'; +import { env } from '#utils/environment'; import { setSentryUser } from '#utils/telemetry'; type Session = typeof auth.$Infer.Session; -export const authenticationMiddleware = createMiddleware( - async (c, next) => { - const session = await auth.api.getSession({ headers: c.req.raw.headers }); +const SIGNATURE_LENGTH = 64; // SHA-256 hex digest - if (session) { - c.set('user', session.user); - c.set('session', session.session); - setSentryUser(session.session, session.user); - return next(); - } +/** + * Verify the signed anonymous-id cookie without minting a new one: a client + * that never got one falls back to IP-only rate limiting. + */ +function verifyAnonymousId(cookieValue: string): string | null { + const dotIndex = cookieValue.lastIndexOf('.'); + if (dotIndex === -1) { + return null; + } - // Fall back to API key authentication (Bearer token or X-API-Key header). - const rawKey = extractApiKey(c.req.raw.headers); - if (rawKey) { - const apiUser = await validateApiKey(rawKey); - if (apiUser) { - c.set('user', apiUser as Session['user']); - // API keys are not tied to a browser session, but downstream - // middleware only needs `session.userId`, so synthesize a minimal - // session object carrying the user id. - c.set('session', { userId: apiUser.id } as Session['session']); - setSentryUser({ userId: apiUser.id } as never, apiUser as never); - return next(); - } - } + const id = cookieValue.slice(0, dotIndex); + const sig = cookieValue.slice(dotIndex + 1); + if (sig.length !== SIGNATURE_LENGTH) { + return null; + } - c.set('user', null); - c.set('session', null); - setSentryUser(null); - return next(); + const expected = createHmac('sha256', env.authSecret) + .update(id) + .digest('hex'); + const sigBuf = Buffer.from(sig, 'hex'); + const expectedBuf = Buffer.from(expected, 'hex'); + if (sigBuf.length !== expectedBuf.length) { + return null; } -); + if (!timingSafeEqual(sigBuf, expectedBuf)) { + return null; + } + + return id; +} + +function readCookie(headers: Headers, name: string): string | null { + return ( + headers + .get('Cookie') + ?.split(';') + .map((cookie) => cookie.trim()) + .find((cookie) => cookie.startsWith(`${name}=`)) + ?.split('=') + .slice(1) + .join('=') ?? null // handle values containing '=' + ); +} + +/** + * Resolve who is calling, once per request, before any procedure runs: a + * better-auth session, an API key (Bearer / `X-API-Key`), or an anonymous id + * from the signed rate-limit cookie. Also seeds Sentry's user context. + */ +export async function resolveCaller( + headers: Headers +): Promise> { + const session = await auth.api.getSession({ headers }); -export const requireAuthentication = createMiddleware( - async (c, next) => { - if (!c.var.session) { - throw new HTTPException(StatusCodes.UNAUTHORIZED, { - message: 'Unauthorized', - }); + if (session) { + setSentryUser(session.session, session.user); + return { anonymousId: null, session: session.session, user: session.user }; + } + + // Fall back to API key authentication (Bearer token or X-API-Key header). + const rawKey = extractApiKey(headers); + if (rawKey) { + const apiUser = await validateApiKey(rawKey); + if (apiUser) { + setSentryUser({ userId: apiUser.id } as never, apiUser as never); + // API keys are not tied to a browser session, but downstream guards only + // need `session.userId`, so synthesize a minimal session object. + return { + anonymousId: null, + session: { userId: apiUser.id } as Session['session'], + user: apiUser as Session['user'], + }; + } + } + + setSentryUser(null); + + const cookie = readCookie(headers, env.rateLimitCookieName); + return { + anonymousId: cookie ? verifyAnonymousId(cookie) : null, + session: null, + user: null, + }; +} + +/** + * Guard for endpoints that only need a caller. Narrows the context so handler + * bodies can read `context.session`/`context.user` without re-checking. + */ +export const requireAuthentication = base.middleware( + ({ context, errors, next }) => { + if (!context.session) { + throw errors.UNAUTHORIZED({ message: 'Unauthorized' }); } - await next(); + return next({ + context: { session: context.session, user: context.user }, + }); } ); +/** Guard for endpoints gated on a permission string from `@filcdev/api/permissions`. */ export const requireAuthorization = (permission: string) => { rbac.registerPermission(permission); - return createMiddleware(async (c, next) => { - if (!c.var.session) { - throw new HTTPException(StatusCodes.UNAUTHORIZED, { - message: 'Unauthorized', - }); + return base.middleware(async ({ context, errors, next }) => { + if (!context.session) { + throw errors.UNAUTHORIZED({ message: 'Unauthorized' }); } - if (!(await userHasPermission(c.var.session.userId, permission))) { - throw new HTTPException(StatusCodes.FORBIDDEN, { - message: 'Forbidden', - }); + if (!(await userHasPermission(context.session.userId, permission))) { + throw errors.FORBIDDEN({ message: 'Forbidden' }); } - await next(); + return next({ + context: { session: context.session, user: context.user }, + }); }); }; - -/** - * Compose the auth middlewares for a route. Pass a permission to also require - * authorization; omit it for authentication-only. Spread into `createHandlers` - * so handlers stop listing the guards individually: - * - * export const listX = factory.createHandlers( - * ...authRouter('x:read'), - * zValidator('query', schema), - * async (c) => ok(c, await listX()), - * ); - */ -export function authRouter(): [typeof requireAuthentication]; -export function authRouter( - permission: string -): [typeof requireAuthentication, ReturnType]; -export function authRouter(permission?: string) { - return permission - ? [requireAuthentication, requireAuthorization(permission)] - : [requireAuthentication]; -} diff --git a/apps/chronos/src/middleware/rate-limit.ts b/apps/chronos/src/middleware/rate-limit.ts new file mode 100644 index 00000000..a5f28c4c --- /dev/null +++ b/apps/chronos/src/middleware/rate-limit.ts @@ -0,0 +1,36 @@ +import { getLogger } from '@logtape/logtape'; +import { MemoryRatelimiter } from '@orpc/experimental-ratelimit/memory'; +import { base } from '#orpc'; +import { env } from '#utils/environment'; + +const logger = getLogger(['chronos', 'server']); + +// Per-process, exactly like the pre-migration limiter it replaces: a single +// instance keeps identical behaviour, replicas would need a shared store. +const limiter = new MemoryRatelimiter({ + maxRequests: env.rateLimitMax, + window: env.rateLimitWindowMs, +}); + +/** `reset` is an absolute Unix timestamp in milliseconds. */ +const retryAfterSeconds = (reset: number | undefined) => + Math.max(1, Math.ceil(((reset ?? Date.now()) - Date.now()) / 1000)); + +/** + * Keys the limiter on the caller (`session.id` ?? `anonymousId` ?? `'unknown'`) + * plus the client IP, mirroring the pre-migration key generator. + */ +export const rateLimit = base.middleware(async ({ context, errors, next }) => { + const key = `${context.session?.id ?? context.anonymousId ?? 'unknown'}|${context.clientIp}`; + const result = await limiter.limit(key); + + if (!result.success) { + logger.debug(`Rate limit exceeded for ${key}`); + throw errors.RATE_LIMITED({ + data: { retryAfter: retryAfterSeconds(result.reset) }, + message: 'Too many requests', + }); + } + + return next(); +}); diff --git a/apps/chronos/src/middleware/security.ts b/apps/chronos/src/middleware/security.ts deleted file mode 100644 index 6159562a..00000000 --- a/apps/chronos/src/middleware/security.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { cors } from 'hono/cors'; -import { secureHeaders } from 'hono/secure-headers'; -import { env } from '#utils/environment'; - -export const securityMiddleware = secureHeaders({ - contentSecurityPolicy: { - baseUri: ["'self'"], - childSrc: ["'self'"], - connectSrc: ["'self'"], - defaultSrc: ["'self'"], - fontSrc: ["'self'", 'https:', 'data:'], - formAction: ["'self'"], - frameAncestors: ["'self'"], - frameSrc: ["'self'"], - imgSrc: ["'self'", 'data:'], - manifestSrc: ["'self'"], - mediaSrc: ["'self'"], - objectSrc: ["'none'"], - reportTo: 'endpoint-1', - sandbox: ['allow-same-origin', 'allow-scripts'], - scriptSrc: ["'self'"], - scriptSrcAttr: ["'none'"], - // let swagger UI load its styles - scriptSrcElem: ["'self' ", 'https:', "'unsafe-inline'"], - styleSrc: ["'self'", 'https:', "'unsafe-inline'"], - styleSrcAttr: ["'none'"], - styleSrcElem: ["'self'", 'https:', "'unsafe-inline'"], - upgradeInsecureRequests: [], - workerSrc: ["'self'"], - }, - // The kiosk SPA is served from a host of its own (a static build with nothing - // proxying /api) and shows announcement images straight from this API, so its - // responses must stay embeddable across origins. Left as `same-origin`, the - // browser blocks the image with ERR_BLOCKED_BY_RESPONSE.NotSameOrigin. - crossOriginResourcePolicy: 'cross-origin', -}); - -export const corsMiddleware = cors({ - allowHeaders: ['Content-Type', 'Authorization'], - allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], - // Local dev accepts any origin; a deployment only accepts its own origins — - // the API itself plus the static apps (the kiosk build) that call it by - // absolute URL from a host of their own. - origin: - env.mode === 'development' - ? '*' - : [env.baseUrl, ...(env.trustedOrigins ?? [])], -}); diff --git a/apps/chronos/src/middleware/timing.ts b/apps/chronos/src/middleware/timing.ts deleted file mode 100644 index 6c8ebfbc..00000000 --- a/apps/chronos/src/middleware/timing.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { getLogger } from '@logtape/logtape'; -import { getConnInfo } from 'hono/bun'; -import { createMiddleware } from 'hono/factory'; -import type { Context } from '#_types/globals'; -import { env } from '#utils/environment'; - -const logger = getLogger(['chronos', 'server']); - -export const timingMiddleware = createMiddleware(async (c, next) => { - const start = Date.now(); - - await next(); - - const ms = Date.now() - start; - - if (env.mode === 'development') { - logger.trace(`${c.req.method} ${c.req.url} - ${ms}ms`, { - duration: ms, - method: c.req.method, - status: c.res.status, - url: c.req.url, - user: c.get('user') - ? { email: c.get('user')?.email, id: c.get('user')?.id } - : null, - }); - - return; - } - - const connInfo = getConnInfo(c); - const remoteAddr = env.realIpHeader - ? c.req.header(env.realIpHeader) - : connInfo?.remote.address; - logger.info('Received request', { - duration: ms, - ip: remoteAddr ?? 'unknown', - method: c.req.method, - status: c.res.status, - ua: c.req.header('user-agent') ?? 'unknown', - url: c.req.url, - user: c.get('user') - ? { email: c.get('user')?.email, id: c.get('user')?.id } - : null, - }); -}); diff --git a/apps/chronos/src/orpc.ts b/apps/chronos/src/orpc.ts new file mode 100644 index 00000000..5fcf1126 --- /dev/null +++ b/apps/chronos/src/orpc.ts @@ -0,0 +1,11 @@ +import { appContract } from '@filcdev/api/contract'; +import { implement } from '@orpc/server'; +import type { ChronosContext } from '#_types/globals'; + +/** + * The implementer every Chronos procedure is built from. Because it is bound + * to the contract, `.handler()`'s input and output types come from + * `@filcdev/api/contract` and `base.router({...})` fails to compile when a + * procedure is missing, extra, or mistyped. + */ +export const base = implement(appContract).$context(); diff --git a/apps/chronos/src/router.ts b/apps/chronos/src/router.ts new file mode 100644 index 00000000..978ff62f --- /dev/null +++ b/apps/chronos/src/router.ts @@ -0,0 +1,39 @@ +import { rateLimit } from '#middleware/rate-limit'; +import { base } from '#orpc'; +import { bugReportRouter } from '#routes/bug-report/_router'; +import { cohortRouter } from '#routes/cohort/_router'; +import { dashboardRouter } from '#routes/dashboard/_router'; +import { doorlockRouter } from '#routes/doorlock/_router'; +import { healthRouter } from '#routes/health/_router'; +import { kioskRouter } from '#routes/kiosk/_router'; +import { navigatorRouter } from '#routes/navigator/_router'; +import { newsRouter } from '#routes/news/_router'; +import { notificationsRouter } from '#routes/notifications/_router'; +import { pingRouter } from '#routes/ping/_router'; +import { rolesRouter } from '#routes/roles/_router'; +import { timetableRouter } from '#routes/timetable/_router'; +import { usersRouter } from '#routes/users/_router'; + +/** + * The implemented contract. `base.router` type-checks this object against + * `appContract`, so a missing, extra or mistyped procedure fails `tsc`. + * + * `use(rateLimit)` is the initial middleware for every procedure, so limiting + * happens before any per-procedure guard — the order the pre-oRPC global + * `rateLimiter` middleware had. + */ +export const appRouter = base.use(rateLimit).router({ + bugReport: bugReportRouter, + cohort: cohortRouter, + dashboard: dashboardRouter, + doorlock: doorlockRouter, + health: healthRouter, + kiosk: kioskRouter, + navigator: navigatorRouter, + news: newsRouter, + notifications: notificationsRouter, + ping: pingRouter, + roles: rolesRouter, + timetable: timetableRouter, + users: usersRouter, +}); diff --git a/apps/chronos/src/routes/bug-report/_factory.ts b/apps/chronos/src/routes/bug-report/_factory.ts deleted file mode 100644 index d49820a8..00000000 --- a/apps/chronos/src/routes/bug-report/_factory.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { createFactory } from 'hono/factory'; -import type { Context } from '#_types/globals'; - -export const bugReportFactory = createFactory(); diff --git a/apps/chronos/src/routes/bug-report/_router.ts b/apps/chronos/src/routes/bug-report/_router.ts index d5f996ed..4f8d670c 100644 --- a/apps/chronos/src/routes/bug-report/_router.ts +++ b/apps/chronos/src/routes/bug-report/_router.ts @@ -1,4 +1,3 @@ -import { bugReportFactory } from '#routes/bug-report/_factory'; import { createBugReport, deleteBugReport, @@ -6,9 +5,9 @@ import { updateBugReportStatus, } from '#routes/bug-report/index'; -export const bugReportRouter = bugReportFactory - .createApp() - .post('/', ...createBugReport) - .get('/', ...listBugReports) - .patch('/:id/status', ...updateBugReportStatus) - .delete('/:id', ...deleteBugReport); +export const bugReportRouter = { + create: createBugReport, + delete: deleteBugReport, + list: listBugReports, + updateStatus: updateBugReportStatus, +}; diff --git a/apps/chronos/src/routes/bug-report/index.ts b/apps/chronos/src/routes/bug-report/index.ts index e4b57851..dcec6c49 100644 --- a/apps/chronos/src/routes/bug-report/index.ts +++ b/apps/chronos/src/routes/bug-report/index.ts @@ -1,6 +1,5 @@ -import { createBugReportSchema } from '@filcdev/api/domains/bug-report'; import { permissions } from '@filcdev/api/permissions'; -import { zValidator } from '@hono/zod-validator'; +import { ORPCError } from '@orpc/server'; import { and, count, @@ -12,74 +11,17 @@ import { or, type SQL, } from 'drizzle-orm'; -import { HTTPException } from 'hono/http-exception'; -import { describeRoute, resolver } from 'hono-openapi'; -import { StatusCodes } from 'http-status-codes'; -import z from 'zod'; import { db } from '#database'; -import { bugReport, bugReportStatuses } from '#database/schema/bug-report'; -import { authRouter } from '#middleware/auth'; -import { bugReportFactory } from '#routes/bug-report/_factory'; -import { created, ok } from '#utils/http'; -import { filcExt } from '#utils/openapi'; -import { createSelectSchema } from '#utils/zod'; +import { bugReport } from '#database/schema/bug-report'; +import { requireAuthentication, requireAuthorization } from '#middleware/auth'; +import { base } from '#orpc'; +import { notFound } from '#utils/http'; -const bugReportSelectSchema = createSelectSchema(bugReport); - -const bugReportQuerySchema = z.object({ - dateFrom: z.iso.datetime().optional(), - dateTo: z.iso.datetime().optional(), - limit: z.coerce.number().int().min(1).max(100).default(20), - page: z.coerce.number().int().min(1).default(1), - search: z.string().optional(), - status: z.enum(bugReportStatuses).optional(), -}); - -const bugReportStatusUpdateSchema = z.object({ - status: z.enum(bugReportStatuses), -}); - -const bugReportListResponseSchema = z.object({ - reports: z.array(bugReportSelectSchema), - total: z.number(), -}); - -const { schema: createRequestSchema } = await resolver( - createBugReportSchema -).toOpenAPISchema(); -const { schema: statusUpdateRequestSchema } = await resolver( - bugReportStatusUpdateSchema -).toOpenAPISchema(); - -export const createBugReport = bugReportFactory.createHandlers( - describeRoute({ - ...filcExt('BugReport', '@unit BugReport', true), - description: 'Create a new bug report', - requestBody: { - content: { - 'application/json': { - schema: createRequestSchema, - }, - }, - }, - responses: { - 201: { - content: { - 'application/json': { - schema: resolver(z.object({ id: z.string().uuid() })), - }, - }, - description: 'Bug report created', - }, - 400: { description: 'Invalid input' }, - }, - tags: ['Bug Reports'], - }), - ...authRouter(), - zValidator('json', createBugReportSchema), - async (c) => { - const { description, metadata, page, subject } = c.req.valid('json'); - const user = c.var.user; +export const createBugReport = base.bugReport.create + .use(requireAuthentication) + .handler(async ({ context, input }) => { + const { description, metadata, page, subject } = input; + const user = context.user; const [inserted] = await db .insert(bugReport) @@ -94,40 +36,18 @@ export const createBugReport = bugReportFactory.createHandlers( .returning({ id: bugReport.id }); if (!inserted) { - throw new HTTPException(StatusCodes.INTERNAL_SERVER_ERROR, { + throw new ORPCError('INTERNAL', { message: 'Failed to store bug report', }); } - return created(c, { id: inserted.id }); - } -); + return { id: inserted.id }; + }); -export const listBugReports = bugReportFactory.createHandlers( - describeRoute({ - ...filcExt( - 'BugReport', - '@unit BugReportListResponse @field(.reports, List)', - true - ), - description: 'List bug reports with optional filters and paging', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(bugReportListResponseSchema), - }, - }, - description: 'Paginated list of bug reports', - }, - }, - tags: ['Bug Reports'], - }), - ...authRouter(permissions.bugReportsRead), - zValidator('query', bugReportQuerySchema), - async (c) => { - const { dateFrom, dateTo, limit, page, search, status } = - c.req.valid('query'); +export const listBugReports = base.bugReport.list + .use(requireAuthorization(permissions.bugReportsRead)) + .handler(async ({ input }) => { + const { dateFrom, dateTo, limit, page, search, status } = input; const conditions: (SQL | undefined)[] = []; @@ -165,40 +85,13 @@ export const listBugReports = bugReportFactory.createHandlers( db.select({ count: count() }).from(bugReport).where(where), ]); - return ok(c, { reports, total: totalResult[0]?.count ?? 0 }); - } -); + return { reports, total: totalResult[0]?.count ?? 0 }; + }); -export const updateBugReportStatus = bugReportFactory.createHandlers( - describeRoute({ - ...filcExt('BugReport', '@unit BugReport', true), - description: 'Update a bug report status', - requestBody: { - content: { - 'application/json': { - schema: statusUpdateRequestSchema, - }, - }, - }, - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(bugReportSelectSchema), - }, - }, - description: 'Bug report status updated', - }, - 404: { description: 'Bug report not found' }, - }, - tags: ['Bug Reports'], - }), - ...authRouter(permissions.bugReportsWrite), - zValidator('param', z.object({ id: z.string().uuid() })), - zValidator('json', bugReportStatusUpdateSchema), - async (c) => { - const { id } = c.req.valid('param'); - const { status } = c.req.valid('json'); +export const updateBugReportStatus = base.bugReport.updateStatus + .use(requireAuthorization(permissions.bugReportsWrite)) + .handler(async ({ input }) => { + const { id, status } = input; const [existing] = await db .select() @@ -206,9 +99,7 @@ export const updateBugReportStatus = bugReportFactory.createHandlers( .where(eq(bugReport.id, id)); if (!existing) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: 'Bug report not found', - }); + throw notFound('Bug report not found'); } const [updated] = await db @@ -218,48 +109,25 @@ export const updateBugReportStatus = bugReportFactory.createHandlers( .returning(); if (!updated) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: 'Bug report not found', - }); + throw notFound('Bug report not found'); } - return ok(c, updated); - } -); + return updated; + }); -export const deleteBugReport = bugReportFactory.createHandlers( - describeRoute({ - ...filcExt('BugReport', '@nodata', true), - description: 'Delete a bug report', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(z.object({ success: z.literal(true) })), - }, - }, - description: 'Bug report deleted', - }, - 404: { description: 'Bug report not found' }, - }, - tags: ['Bug Reports'], - }), - ...authRouter(permissions.bugReportsWrite), - zValidator('param', z.object({ id: z.string().uuid() })), - async (c) => { - const { id } = c.req.valid('param'); +export const deleteBugReport = base.bugReport.delete + .use(requireAuthorization(permissions.bugReportsWrite)) + .handler(async ({ input }) => { + const { id } = input; const [deleted] = await db .delete(bugReport) .where(eq(bugReport.id, id)) - .returning(); + .returning({ id: bugReport.id }); if (!deleted) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: 'Bug report not found', - }); + throw notFound('Bug report not found'); } - return ok(c, undefined); - } -); + return { id: deleted.id }; + }); diff --git a/apps/chronos/src/routes/cohort/_factory.ts b/apps/chronos/src/routes/cohort/_factory.ts deleted file mode 100644 index 6f837443..00000000 --- a/apps/chronos/src/routes/cohort/_factory.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { createFactory } from 'hono/factory'; -import type { Context } from '#_types/globals'; - -export const cohortFactory = createFactory(); diff --git a/apps/chronos/src/routes/cohort/_router.ts b/apps/chronos/src/routes/cohort/_router.ts index 554964dc..06a81a15 100644 --- a/apps/chronos/src/routes/cohort/_router.ts +++ b/apps/chronos/src/routes/cohort/_router.ts @@ -1,4 +1,3 @@ -import { cohortFactory } from '#routes/cohort/_factory'; import { listCohorts } from '#routes/cohort/index'; -export const cohortRouter = cohortFactory.createApp().get('/', ...listCohorts); +export const cohortRouter = { cohort: listCohorts }; diff --git a/apps/chronos/src/routes/cohort/index.ts b/apps/chronos/src/routes/cohort/index.ts index db78eb4b..f948af60 100644 --- a/apps/chronos/src/routes/cohort/index.ts +++ b/apps/chronos/src/routes/cohort/index.ts @@ -1,36 +1,9 @@ -import { describeRoute, resolver } from 'hono-openapi'; -import { z } from 'zod'; import { db } from '#database'; import { cohort } from '#database/schema/timetable'; -import { cohortFactory } from '#routes/cohort/_factory'; -import { ok } from '#utils/http'; -import { filcExt } from '#utils/openapi'; -import { createSelectSchema } from '#utils/zod'; +import { base } from '#orpc'; -const listCohortsResponseSchema = z.object({ - data: z.array(createSelectSchema(cohort)), - success: z.literal(true), -}); - -export const listCohorts = cohortFactory.createHandlers( - describeRoute({ - ...filcExt('Cohort', '@listof Cohort'), - description: 'List all cohorts', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(listCohortsResponseSchema), - }, - }, - description: 'Successful response', - }, - }, - tags: ['Cohort'], - }), - async (c) => { - const cohorts = await db.select().from(cohort); +export const listCohorts = base.cohort.cohort.handler(async () => { + const cohorts = await db.select().from(cohort); - return ok(c, cohorts); - } -); + return cohorts; +}); diff --git a/apps/chronos/src/routes/dashboard/_factory.ts b/apps/chronos/src/routes/dashboard/_factory.ts deleted file mode 100644 index d9e34ae5..00000000 --- a/apps/chronos/src/routes/dashboard/_factory.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { createFactory } from 'hono/factory'; -import type { Context } from '#_types/globals'; - -export const dashboardFactory = createFactory(); diff --git a/apps/chronos/src/routes/dashboard/_router.ts b/apps/chronos/src/routes/dashboard/_router.ts index f681e60b..2dab2582 100644 --- a/apps/chronos/src/routes/dashboard/_router.ts +++ b/apps/chronos/src/routes/dashboard/_router.ts @@ -1,6 +1,3 @@ -import { dashboardFactory } from '#routes/dashboard/_factory'; import { getDashboardStats } from '#routes/dashboard/index'; -export const dashboardRouter = dashboardFactory - .createApp() - .get('/stats', ...getDashboardStats); +export const dashboardRouter = { stats: getDashboardStats }; diff --git a/apps/chronos/src/routes/dashboard/index.ts b/apps/chronos/src/routes/dashboard/index.ts index 2da98285..a5ef53b7 100644 --- a/apps/chronos/src/routes/dashboard/index.ts +++ b/apps/chronos/src/routes/dashboard/index.ts @@ -1,17 +1,9 @@ -import { - dashboardStatsResponseSchema, - statsQuerySchema, -} from '@filcdev/api/domains/dashboard'; -import { zValidator } from '@hono/zod-validator'; import { and, count, desc, gte, isNotNull, lte } from 'drizzle-orm'; -import { describeRoute, resolver } from 'hono-openapi'; import { db } from '#database'; import { user } from '#database/schema/authentication'; import { role } from '#database/schema/authorization'; import { cohort, movedLesson, substitution } from '#database/schema/timetable'; -import { dashboardFactory } from '#routes/dashboard/_factory'; -import { ok } from '#utils/http'; -import { filcExt } from '#utils/openapi'; +import { base } from '#orpc'; import { getActiveTimetableId } from '#utils/timetable/active'; function fmtDate(d: Date): string { @@ -21,25 +13,9 @@ function fmtDate(d: Date): string { return `${y}-${m}-${day}`; } -export const getDashboardStats = dashboardFactory.createHandlers( - describeRoute({ - ...filcExt('Dashboard', '@unit DashboardStatsResponse'), - description: 'Get aggregated dashboard statistics', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(dashboardStatsResponseSchema), - }, - }, - description: 'Successful response', - }, - }, - tags: ['Dashboard'], - }), - zValidator('query', statsQuerySchema), - async (c) => { - const { days } = c.req.valid('query'); +export const getDashboardStats = base.dashboard.stats.handler( + async ({ input }) => { + const { days } = input; const today = new Date(); today.setHours(0, 0, 0, 0); @@ -118,7 +94,7 @@ export const getDashboardStats = dashboardFactory.createHandlers( .sort(([a], [b]) => a.localeCompare(b)) .map(([date, counts]) => ({ date, ...counts })); - return ok(c, { + return { stats: { chartData, chartTotalMovedLessons: movedLessonDates.length, @@ -129,6 +105,6 @@ export const getDashboardStats = dashboardFactory.createHandlers( totalSubstitutions: liveSubsRow?.count ?? 0, totalUsers: usersRow?.count ?? 0, }, - }); + }; } ); diff --git a/apps/chronos/src/routes/doorlock/_factory.ts b/apps/chronos/src/routes/doorlock/_factory.ts deleted file mode 100644 index d8123a5c..00000000 --- a/apps/chronos/src/routes/doorlock/_factory.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { createFactory } from 'hono/factory'; -import type { Context } from '#_types/globals'; - -export const doorlockFactory = createFactory< - Context & { - Variables: { - device: { id: string; name: string } | undefined; - }; - } ->(); diff --git a/apps/chronos/src/routes/doorlock/_router.ts b/apps/chronos/src/routes/doorlock/_router.ts index c5ad0ba1..2aa87481 100644 --- a/apps/chronos/src/routes/doorlock/_router.ts +++ b/apps/chronos/src/routes/doorlock/_router.ts @@ -1,54 +1,55 @@ -import { doorlockFactory } from '#routes/doorlock/_factory'; import { - createCardRoute, - deleteCardRoute, - listCardsRoute, - listDoorlockUsersRoute, - updateCardRoute, + createCard, + deleteCard, + listCards, + listDoorlockUsers, + updateCard, } from '#routes/doorlock/cards'; import { - createDeviceRoute, - deleteDeviceRoute, - listDevicesRoute, - updateDeviceRoute, + createDevice, + deleteDevice, + listDevices, + updateDevice, } from '#routes/doorlock/devices'; -import { exportLogsRoute } from '#routes/doorlock/export'; -import { listLogsRoute } from '#routes/doorlock/logs'; +import { exportLogs } from '#routes/doorlock/export'; +import { listLogs } from '#routes/doorlock/logs'; +import { triggerBulkOta, triggerDeviceOta } from '#routes/doorlock/ota'; import { - triggerBulkOtaRoute, - triggerDeviceOtaRoute, -} from '#routes/doorlock/ota'; -import { - activateVirtualCardRoute, - listSelfCardsRoute, - updateSelfCardFrozenRoute, + activateVirtualCard, + listSelfCards, + updateSelfCardFrozen, } from '#routes/doorlock/self'; -import { deviceStatsRoute, doorlockStatsRoute } from '#routes/doorlock/stats'; -import { websocketHandler } from '#routes/doorlock/websocket-handler'; +import { deviceStats, doorlockStats } from '#routes/doorlock/stats'; -export const doorlockRouter = doorlockFactory - .createApp() - .get('/ws', ...websocketHandler) - // Device routes - .get('/devices', ...listDevicesRoute) - .post('/devices', ...createDeviceRoute) - .put('/devices/:id', ...updateDeviceRoute) - .delete('/devices/:id', ...deleteDeviceRoute) - .get('/devices/:id/stats', ...deviceStatsRoute) - // OTA routes - .post('/devices/:id/update', ...triggerDeviceOtaRoute) - .post('/devices/update', ...triggerBulkOtaRoute) - // Card routes - .get('/cards', ...listCardsRoute) - .post('/cards', ...createCardRoute) - .put('/cards/:id', ...updateCardRoute) - .delete('/cards/:id', ...deleteCardRoute) - .get('/cards/users', ...listDoorlockUsersRoute) - // Logs & stats - .get('/logs', ...listLogsRoute) - .get('/logs/export', ...exportLogsRoute) - .get('/stats/overview', ...doorlockStatsRoute) - // Self-service routes - .get('/self/cards', ...listSelfCardsRoute) - .put('/self/cards/:id/frozen', ...updateSelfCardFrozenRoute) - .post('/self/cards/:id/activate', ...activateVirtualCardRoute); +export const doorlockRouter = { + cards: { + create: createCard, + delete: deleteCard, + list: listCards, + update: updateCard, + users: listDoorlockUsers, + }, + devices: { + create: createDevice, + delete: deleteDevice, + list: listDevices, + stats: deviceStats, + triggerOta: triggerDeviceOta, + update: updateDevice, + updateAll: triggerBulkOta, + }, + logs: { + export: exportLogs, + list: listLogs, + }, + self: { + cards: { + activate: activateVirtualCard, + list: listSelfCards, + setFrozen: updateSelfCardFrozen, + }, + }, + stats: { + overview: doorlockStats, + }, +}; diff --git a/apps/chronos/src/routes/doorlock/cards.ts b/apps/chronos/src/routes/doorlock/cards.ts index f6cefaba..30633d15 100644 --- a/apps/chronos/src/routes/doorlock/cards.ts +++ b/apps/chronos/src/routes/doorlock/cards.ts @@ -1,18 +1,11 @@ -import { - createCardSchema, - updateCardSchema, -} from '@filcdev/api/domains/doorlock/cards'; -import { idParamSchema } from '@filcdev/api/domains/doorlock/devices'; -import { zValidator } from '@hono/zod-validator'; +import { permissions } from '@filcdev/api/permissions'; +import { ORPCError } from '@orpc/server'; import { eq, sql } from 'drizzle-orm'; -import { HTTPException } from 'hono/http-exception'; -import { describeRoute, resolver } from 'hono-openapi'; -import { StatusCodes } from 'http-status-codes'; -import z from 'zod'; import { db } from '#database'; import { user } from '#database/schema/authentication'; -import { card, device } from '#database/schema/doorlock'; -import { authRouter } from '#middleware/auth'; +import { card } from '#database/schema/doorlock'; +import { requireAuthorization } from '#middleware/auth'; +import { base } from '#orpc'; import { type DoorlockCardWithRelations, fetchCardById, @@ -21,53 +14,7 @@ import { replaceCardDevices, } from '#utils/doorlock/cards'; import { syncDevicesByIds } from '#utils/doorlock/device-sync'; -import { created, notFound, ok } from '#utils/http'; -import { filcExt } from '#utils/openapi'; -import { createSelectSchema } from '#utils/zod'; -import { doorlockFactory } from './_factory'; - -const cardSelectSchema = createSelectSchema(card); -const deviceSummarySchema = createSelectSchema(device).pick({ - id: true, - name: true, -}); -const userSummarySchema = createSelectSchema(user).pick({ - email: true, - id: true, - name: true, - nickname: true, -}); - -export const cardWithRelationsSchema = cardSelectSchema.extend({ - authorizedDevices: z.array(deviceSummarySchema), - owner: userSummarySchema.nullable().optional(), -}); - -export const cardsResponseSchema = z.object({ - data: z.object({ - cards: z.array(cardWithRelationsSchema), - }), - success: z.literal(true), -}); - -export const cardResponseSchema = z.object({ - data: z.object({ - card: cardWithRelationsSchema, - }), - success: z.literal(true), -}); - -const usersResponseSchema = z.object({ - data: z.object({ - users: z.array(userSummarySchema), - }), - success: z.literal(true), -}); - -const { schema: createCardRequestSchema } = - await resolver(createCardSchema).toOpenAPISchema(); -const { schema: updateCardRequestSchema } = - await resolver(updateCardSchema).toOpenAPISchema(); +import { notFound } from '#utils/http'; const assertCardExists = (cardRecord?: DoorlockCardWithRelations | null) => { if (!cardRecord) { @@ -76,56 +23,17 @@ const assertCardExists = (cardRecord?: DoorlockCardWithRelations | null) => { return cardRecord; }; -export const listCardsRoute = doorlockFactory.createHandlers( - describeRoute({ - ...filcExt( - 'Doorlock', - '@unit CardListResponse @field(.cards, List)', - true - ), - description: 'List all access cards', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(cardsResponseSchema), - }, - }, - description: 'Successful response', - }, - }, - tags: ['Doorlock'], - }), - ...authRouter('doorlock:cards:read'), - async (c) => { +export const listCards = base.doorlock.cards.list + .use(requireAuthorization(permissions.doorlockCardsRead)) + .handler(async () => { const cards = await fetchCards(); - return ok(c, { cards }); - } -); + return { cards }; + }); -export const listDoorlockUsersRoute = doorlockFactory.createHandlers( - describeRoute({ - ...filcExt( - 'Doorlock', - '@unit DoorlockUserListResponse @field(.users, List)', - true - ), - description: 'List users eligible for card ownership', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(usersResponseSchema), - }, - }, - description: 'Successful response', - }, - }, - tags: ['Doorlock'], - }), - ...authRouter('doorlock:cards:write'), - async (c) => { +export const listDoorlockUsers = base.doorlock.cards.users + .use(requireAuthorization(permissions.doorlockCardsWrite)) + .handler(async () => { const usersList = await db .select({ email: user.email, @@ -136,38 +44,12 @@ export const listDoorlockUsersRoute = doorlockFactory.createHandlers( .from(user) .orderBy(sql`coalesce(${user.nickname}, ${user.name})`); - return ok(c, { users: usersList }); - } -); - -export const createCardRoute = doorlockFactory.createHandlers( - describeRoute({ - ...filcExt('Doorlock', '@unit CardResponse @field(.card, Card)', true), - description: 'Create a new access card', - requestBody: { - content: { - 'application/json': { - schema: createCardRequestSchema, - }, - }, - }, - responses: { - 201: { - content: { - 'application/json': { - schema: resolver(cardResponseSchema), - }, - }, - description: 'Card created', - }, - }, - tags: ['Doorlock'], - }), - ...authRouter('doorlock:cards:write'), - zValidator('json', createCardSchema), - async (c) => { - const payload = c.req.valid('json'); + return { users: usersList }; + }); +export const createCard = base.doorlock.cards.create + .use(requireAuthorization(permissions.doorlockCardsWrite)) + .handler(async ({ input: payload }) => { const cardId = await db.transaction(async (tx) => { const [inserted] = await tx .insert(card) @@ -181,7 +63,7 @@ export const createCardRoute = doorlockFactory.createHandlers( .returning({ id: card.id }); if (!inserted) { - throw new HTTPException(StatusCodes.INTERNAL_SERVER_ERROR, { + throw new ORPCError('INTERNAL', { message: 'Failed to create card', }); } @@ -206,40 +88,13 @@ export const createCardRoute = doorlockFactory.createHandlers( ) ); - return created(c, { card: createdCard }); - } -); + return { card: createdCard }; + }); -export const updateCardRoute = doorlockFactory.createHandlers( - describeRoute({ - ...filcExt('Doorlock', '@unit CardResponse @field(.card, Card)', true), - description: 'Update an access card', - requestBody: { - content: { - 'application/json': { - schema: updateCardRequestSchema, - }, - }, - }, - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(cardResponseSchema), - }, - }, - description: 'Card updated', - }, - 404: { description: 'Card not found' }, - }, - tags: ['Doorlock'], - }), - ...authRouter('doorlock:cards:write'), - zValidator('json', updateCardSchema), - zValidator('param', idParamSchema), - async (c) => { - const { id: cardId } = c.req.valid('param'); - const payload = c.req.valid('json'); +export const updateCard = base.doorlock.cards.update + .use(requireAuthorization(permissions.doorlockCardsWrite)) + .handler(async ({ input }) => { + const { id: cardId, ...payload } = input; await db.transaction(async (tx) => { const [updated] = await tx @@ -254,9 +109,7 @@ export const updateCardRoute = doorlockFactory.createHandlers( .returning({ id: card.id }); if (!updated) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: 'Card not found', - }); + throw notFound('Card not found'); } await replaceCardDevices(tx, cardId, payload.authorizedDeviceIds); @@ -270,24 +123,13 @@ export const updateCardRoute = doorlockFactory.createHandlers( ) ); - return ok(c, { card: updatedCard }); - } -); + return { card: updatedCard }; + }); -export const deleteCardRoute = doorlockFactory.createHandlers( - describeRoute({ - ...filcExt('Doorlock', '@nodata', true), - description: 'Delete an access card', - responses: { - 200: { description: 'Card deleted' }, - 404: { description: 'Card not found' }, - }, - tags: ['Doorlock'], - }), - ...authRouter('doorlock:cards:write'), - zValidator('param', idParamSchema), - async (c) => { - const { id: cardId } = c.req.valid('param'); +export const deleteCard = base.doorlock.cards.delete + .use(requireAuthorization(permissions.doorlockCardsWrite)) + .handler(async ({ input }) => { + const cardId = input.id; const existingCard = await fetchCardById(cardId); if (!existingCard) { @@ -308,6 +150,5 @@ export const deleteCardRoute = doorlockFactory.createHandlers( ) ); - return ok(c, undefined); - } -); + return deleted; + }); diff --git a/apps/chronos/src/routes/doorlock/websocket-handler.ts b/apps/chronos/src/routes/doorlock/device-socket.ts similarity index 58% rename from apps/chronos/src/routes/doorlock/websocket-handler.ts rename to apps/chronos/src/routes/doorlock/device-socket.ts index f01b941b..16c83514 100644 --- a/apps/chronos/src/routes/doorlock/websocket-handler.ts +++ b/apps/chronos/src/routes/doorlock/device-socket.ts @@ -1,8 +1,5 @@ import { getLogger } from '@logtape/logtape'; -import type { ServerWebSocket } from 'bun'; import { and, eq } from 'drizzle-orm'; -import { upgradeWebSocket } from 'hono/bun'; -import { HTTPException } from 'hono/http-exception'; import { z } from 'zod'; import { db } from '#database'; import { @@ -13,7 +10,6 @@ import { device as lockDevice, } from '#database/schema/doorlock'; import { server } from '#index'; -import { doorlockFactory } from '#routes/doorlock/_factory'; import { extractApiKey, validateApiKey } from '#utils/api-keys'; import { userHasPermission } from '#utils/authorization'; import { @@ -21,11 +17,15 @@ import { type OutgoingMessage, outgoingMessageSchema, } from '#utils/doorlock/schemas'; -import { unauthorized } from '#utils/http'; import { dispatchImmediateNotification } from '#utils/notifications/engine'; const logger = getLogger(['chronos', 'doorlock', 'websocket']); +export const DOORLOCK_SOCKET_PATH = '/api/doorlock/ws'; + +/** What a connected socket carries: the device it authenticated as. */ +export type DeviceSocketData = { device: { id: string; name: string } }; + const handleIncomingMessage = async ( message: string, device: { id: string; name: string } @@ -189,124 +189,142 @@ export const syncDatabase = async (deviceId: string) => { ); }; -export const websocketHandler = doorlockFactory.createHandlers( - async (c, next) => { - // Primary auth: the device's provisioned token. - const gotToken = c.req.header('X-Aegis-Device-Token'); - - if (gotToken) { - const [device] = await db - .select() - .from(lockDevice) - .where(eq(lockDevice.apiToken, gotToken)) - .limit(1); - - if (!device) { - logger.debug('WebSocket connection attempt with invalid device token', { - token: gotToken, - }); - return c.status(401); - } +/** + * Authenticate the connecting device and hand the socket to Bun. The aegis + * firmware speaks a hand-rolled JSON protocol, so this endpoint is deliberately + * not part of the oRPC contract. + */ +export async function upgrade( + request: Request, + srv: Bun.Server +): Promise { + // Primary auth: the device's provisioned token. + const gotToken = request.headers.get('X-Aegis-Device-Token'); - const d = { id: device.id, name: device.name }; - logger.debug('WebSocket connection authorized via device token', { - device: d, + if (gotToken) { + const [device] = await db + .select() + .from(lockDevice) + .where(eq(lockDevice.apiToken, gotToken)) + .limit(1); + + if (!device) { + logger.debug('WebSocket connection attempt with invalid device token', { + token: gotToken, }); - c.set('device', d); - return next(); + return new Response(null, { status: 401 }); } - // Alternative auth: a user-generated API key (Bearer / X-API-Key). - // The connecting client must also identify which device it represents. - const rawKey = extractApiKey(c.req.raw.headers); - if (rawKey) { - const apiUser = await validateApiKey(rawKey); - if (!apiUser) { - logger.debug('WebSocket connection attempt with invalid API key'); - return c.status(401); - } + const d = { id: device.id, name: device.name }; + logger.debug('WebSocket connection authorized via device token', { + device: d, + }); + return connect(request, srv, d); + } - const canControl = await userHasPermission( - apiUser.id, - 'doorlock:devices:read' - ); - if (!canControl) { - logger.debug('WebSocket connection with insufficient API key scope', { - userId: apiUser.id, - }); - return c.status(403); - } + // Alternative auth: a user-generated API key (Bearer / X-API-Key). + // The connecting client must also identify which device it represents. + const rawKey = extractApiKey(request.headers); + if (rawKey) { + const apiUser = await validateApiKey(rawKey); + if (!apiUser) { + logger.debug('WebSocket connection attempt with invalid API key'); + return new Response(null, { status: 401 }); + } - const deviceId = c.req.query('deviceId'); - if (!deviceId) { - throw new HTTPException(400, { - message: 'deviceId query parameter is required when using an API key', - }); - } + const canControl = await userHasPermission( + apiUser.id, + 'doorlock:devices:read' + ); + if (!canControl) { + logger.debug('WebSocket connection with insufficient API key scope', { + userId: apiUser.id, + }); + return new Response(null, { status: 403 }); + } - const [device] = await db - .select() - .from(lockDevice) - .where(eq(lockDevice.id, deviceId)) - .limit(1); + const deviceId = new URL(request.url).searchParams.get('deviceId'); + if (!deviceId) { + return Response.json( + { + message: 'deviceId query parameter is required when using an API key', + }, + { status: 400 } + ); + } - if (!device) { - throw new HTTPException(404, { message: 'Device not found' }); - } + const [device] = await db + .select() + .from(lockDevice) + .where(eq(lockDevice.id, deviceId)) + .limit(1); - const d = { id: device.id, name: device.name }; - logger.debug('WebSocket connection authorized via API key', { - device: d, - userId: apiUser.id, - }); - c.set('device', d); - return next(); + if (!device) { + return Response.json({ message: 'Device not found' }, { status: 404 }); } - logger.warn('WebSocket connection attempt without any credentials'); - throw new HTTPException(401, { + const d = { id: device.id, name: device.name }; + logger.debug('WebSocket connection authorized via API key', { + device: d, + userId: apiUser.id, + }); + return connect(request, srv, d); + } + + logger.warn('WebSocket connection attempt without any credentials'); + return Response.json( + { message: 'Authentication required: provide X-Aegis-Device-Token or an API key', + }, + { status: 401 } + ); +} + +function connect( + request: Request, + srv: Bun.Server, + device: { id: string; name: string } +): Response | undefined { + if (!srv.upgrade(request, { data: { device } })) { + return new Response('WebSocket upgrade failed', { status: 400 }); + } + + return undefined; +} + +export const handlers: Bun.WebSocketHandler = { + close(ws) { + const { device } = ws.data; + logger.info('Connection closed for device', { device }); + logger.trace('Unsubscribing from device channel', { + channel: `device-${device.id}`, + device, }); + ws.unsubscribe(`device-${device.id}`); }, - upgradeWebSocket((c) => { - const device = c.get('device') as { id: string; name: string } | undefined; - if (!device) { - throw unauthorized('Device not found in context'); + async message(ws, raw) { + const { device } = ws.data; + const message = typeof raw === 'string' ? raw : ''; + logger.trace('WebSocket message event received', { + device, + isText: typeof raw === 'string', + }); + const result = await handleIncomingMessage(message, device); + if (result) { + ws.send(JSON.stringify(result)); } + }, - return { - onClose: (_e, ws) => { - logger.info('Connection closed for device', { device }); - const raw = ws.raw as ServerWebSocket; - logger.trace('Unsubscribing from device channel', { - channel: `device-${device.id}`, - device, - }); - raw.unsubscribe(`device-${device.id}`); - }, - async onMessage(event, ws) { - const message = typeof event.data === 'string' ? event.data : ''; - logger.trace('WebSocket message event received', { - device, - isText: typeof event.data === 'string', - }); - const result = await handleIncomingMessage(message, device); - if (result) { - ws.send(JSON.stringify(result)); - } - }, - async onOpen(_e, ws) { - logger.debug('WebSocket connection opened', { device }); - const raw = ws.raw as ServerWebSocket; - logger.trace('Subscribing to device channel', { - channel: `device-${device.id}`, - device, - }); - raw.subscribe(`device-${device.id}`); - await syncDatabase(device.id); - }, - }; - }) -); + async open(ws) { + const { device } = ws.data; + logger.debug('WebSocket connection opened', { device }); + logger.trace('Subscribing to device channel', { + channel: `device-${device.id}`, + device, + }); + ws.subscribe(`device-${device.id}`); + await syncDatabase(device.id); + }, +}; diff --git a/apps/chronos/src/routes/doorlock/devices.ts b/apps/chronos/src/routes/doorlock/devices.ts index 19fa10f7..6946f31b 100644 --- a/apps/chronos/src/routes/doorlock/devices.ts +++ b/apps/chronos/src/routes/doorlock/devices.ts @@ -1,44 +1,16 @@ -import { - type DevicePayloadInput, - devicePayloadSchema, - idParamSchema, -} from '@filcdev/api/domains/doorlock/devices'; -import { zValidator } from '@hono/zod-validator'; +import type { DevicePayloadInput } from '@filcdev/api/domains/doorlock/devices'; +import { permissions } from '@filcdev/api/permissions'; import { getLogger } from '@logtape/logtape'; +import { ORPCError } from '@orpc/server'; import { desc, eq } from 'drizzle-orm'; -import { HTTPException } from 'hono/http-exception'; -import { describeRoute, resolver } from 'hono-openapi'; -import { StatusCodes } from 'http-status-codes'; -import z from 'zod'; import { db } from '#database'; import { device } from '#database/schema/doorlock'; -import { authRouter } from '#middleware/auth'; -import { created, notFound, ok } from '#utils/http'; -import { filcExt } from '#utils/openapi'; -import { createSelectSchema } from '#utils/zod'; -import { doorlockFactory } from './_factory'; +import { requireAuthorization } from '#middleware/auth'; +import { base } from '#orpc'; +import { conflict, notFound } from '#utils/http'; const logger = getLogger(['chronos', 'doorlock', 'devices']); -const deviceSelectSchema = createSelectSchema(device); - -const { schema: devicePayloadRequestSchema } = - await resolver(devicePayloadSchema).toOpenAPISchema(); - -const devicesResponseSchema = z.object({ - data: z.object({ - devices: z.array(deviceSelectSchema), - }), - success: z.literal(true), -}); - -const deviceResponseSchema = z.object({ - data: z.object({ - device: deviceSelectSchema, - }), - success: z.literal(true), -}); - function mapDevicePayload(payload: DevicePayloadInput) { return { apiToken: payload.apiToken, @@ -55,130 +27,55 @@ function buildConstraintError(error: unknown) { 'constraint' in error && (error as { constraint?: string }).constraint === 'device_api_token_unique' ) { - return new HTTPException(StatusCodes.CONFLICT, { - message: 'A device with this API token already exists.', - }); + return conflict('A device with this API token already exists.'); } return null; } -export const listDevicesRoute = doorlockFactory.createHandlers( - describeRoute({ - ...filcExt( - 'Doorlock', - '@unit DeviceListResponse @field(.devices, List)', - true - ), - description: 'List all doorlock devices', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(devicesResponseSchema), - }, - }, - description: 'Successful Response', - }, - }, - tags: ['Doorlock'], - }), - ...authRouter('doorlock:devices:read'), - async (c) => { +export const listDevices = base.doorlock.devices.list + .use(requireAuthorization(permissions.doorlockDevicesRead)) + .handler(async () => { const devices = await db .select() .from(device) .orderBy(desc(device.updatedAt)); - return ok(c, { devices }); - } -); - -export const createDeviceRoute = doorlockFactory.createHandlers( - describeRoute({ - ...filcExt( - 'Doorlock', - '@unit DeviceResponse @field(.device, Device)', - true - ), - description: 'Create a new doorlock device', - requestBody: { - content: { - 'application/json': { - schema: devicePayloadRequestSchema, - }, - }, - }, - responses: { - 201: { - content: { - 'application/json': { - schema: resolver(deviceResponseSchema), - }, - }, - description: 'Device created', - }, - }, - tags: ['Doorlock'], - }), - ...authRouter('doorlock:devices:write'), - zValidator('json', devicePayloadSchema), - async (c) => { - const payload = c.req.valid('json'); + return { devices }; + }); +export const createDevice = base.doorlock.devices.create + .use(requireAuthorization(permissions.doorlockDevicesWrite)) + .handler(async ({ input }) => { try { const [inserted] = await db .insert(device) - .values(mapDevicePayload(payload)) + .values(mapDevicePayload(input)) .returning(); - return created(c, { device: inserted }); + if (!inserted) { + throw new ORPCError('INTERNAL', { + message: 'Failed to create device', + }); + } + + return { device: inserted }; } catch (error) { logger.error('Failed to create device', { error }); const knownError = buildConstraintError(error); if (knownError) { throw knownError; } - throw new HTTPException(StatusCodes.INTERNAL_SERVER_ERROR, { + throw new ORPCError('INTERNAL', { + cause: error, message: 'Failed to create device', }); } - } -); + }); -export const updateDeviceRoute = doorlockFactory.createHandlers( - describeRoute({ - ...filcExt( - 'Doorlock', - '@unit DeviceResponse @field(.device, Device)', - true - ), - description: 'Update an existing doorlock device', - requestBody: { - content: { - 'application/json': { - schema: devicePayloadRequestSchema, - }, - }, - }, - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(deviceResponseSchema), - }, - }, - description: 'Device updated', - }, - 404: { description: 'Device not found' }, - }, - tags: ['Doorlock'], - }), - ...authRouter('doorlock:devices:write'), - zValidator('json', devicePayloadSchema), - zValidator('param', idParamSchema), - async (c) => { - const { id } = c.req.valid('param'); - const payload = c.req.valid('json'); +export const updateDevice = base.doorlock.devices.update + .use(requireAuthorization(permissions.doorlockDevicesWrite)) + .handler(async ({ input }) => { + const { id, ...payload } = input; try { const [updated] = await db @@ -188,51 +85,37 @@ export const updateDeviceRoute = doorlockFactory.createHandlers( .returning(); if (!updated) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: 'Device not found', - }); + throw notFound('Device not found'); } - return ok(c, { device: updated }); + return { device: updated }; } catch (error) { logger.error('Failed to update device', { error }); const knownError = buildConstraintError(error); if (knownError) { throw knownError; } - if (error instanceof HTTPException) { + if (error instanceof ORPCError) { throw error; } - throw new HTTPException(StatusCodes.INTERNAL_SERVER_ERROR, { + throw new ORPCError('INTERNAL', { + cause: error, message: 'Failed to update device', }); } - } -); - -export const deleteDeviceRoute = doorlockFactory.createHandlers( - describeRoute({ - ...filcExt('Doorlock', '@nodata', true), - description: 'Delete a doorlock device', - responses: { - 200: { description: 'Device deleted' }, - }, - tags: ['Doorlock'], - }), - ...authRouter('doorlock:devices:write'), - zValidator('param', idParamSchema), - async (c) => { - const { id } = c.req.valid('param'); + }); +export const deleteDevice = base.doorlock.devices.delete + .use(requireAuthorization(permissions.doorlockDevicesWrite)) + .handler(async ({ input }) => { const [deleted] = await db .delete(device) - .where(eq(device.id, id)) - .returning(); + .where(eq(device.id, input.id)) + .returning({ id: device.id }); if (!deleted) { throw notFound('Device not found'); } - return ok(c, undefined); - } -); + return deleted; + }); diff --git a/apps/chronos/src/routes/doorlock/export.ts b/apps/chronos/src/routes/doorlock/export.ts index 4ac9466c..f21e09f2 100644 --- a/apps/chronos/src/routes/doorlock/export.ts +++ b/apps/chronos/src/routes/doorlock/export.ts @@ -1,17 +1,12 @@ -import { - type ExportQueryInput, - exportQuerySchema, -} from '@filcdev/api/domains/doorlock/export'; +import type { ExportQueryInput } from '@filcdev/api/domains/doorlock/export'; import { permissions } from '@filcdev/api/permissions'; import type { SQL } from 'drizzle-orm'; import { and, desc, eq, gte, ilike, lte, or } from 'drizzle-orm'; -import { describeRoute } from 'hono-openapi'; import { db } from '#database'; import { user } from '#database/schema/authentication'; import { auditLog, card, device } from '#database/schema/doorlock'; -import { requireAuthentication, requireAuthorization } from '#middleware/auth'; -import { filcExt } from '#utils/openapi'; -import { doorlockFactory } from './_factory'; +import { requireAuthorization } from '#middleware/auth'; +import { base } from '#orpc'; const appendBaseFilters = ( filters: SQL[], @@ -142,30 +137,9 @@ const toCsv = ( return [header.join(','), ...lines].join('\n'); }; -export const exportLogsRoute = doorlockFactory.createHandlers( - describeRoute({ - ...filcExt('Doorlock', 'Export audit log entries as CSV', true), - description: - 'Export doorlock audit log entries (attendance data) as a CSV file over an optional date range.', - responses: { - 200: { - content: { - 'text/csv': { - schema: { format: 'binary', type: 'string' }, - }, - }, - description: 'CSV file with the requested log entries', - }, - }, - tags: ['Doorlock'], - }), - requireAuthentication, - requireAuthorization(permissions.doorlockLogsRead), - async (c) => { - const url = new URL(c.req.url); - const queryParams = Object.fromEntries(url.searchParams.entries()); - const query = exportQuerySchema.parse(queryParams); - +export const exportLogs = base.doorlock.logs.export + .use(requireAuthorization(permissions.doorlockLogsRead)) + .handler(async ({ input: query }) => { const rows = await db .select({ buttonPressed: auditLog.buttonPressed, @@ -197,9 +171,5 @@ export const exportLogsRoute = doorlockFactory.createHandlers( const filename = `doorlock-export-${new Date().toISOString().slice(0, 10)}.csv`; - c.header('Content-Type', 'text/csv; charset=utf-8'); - c.header('Content-Disposition', `attachment; filename="${filename}"`); - - return c.body(csv); - } -); + return new File([csv], filename, { type: 'text/csv; charset=utf-8' }); + }); diff --git a/apps/chronos/src/routes/doorlock/logs.ts b/apps/chronos/src/routes/doorlock/logs.ts index 453ffaa3..a646cea3 100644 --- a/apps/chronos/src/routes/doorlock/logs.ts +++ b/apps/chronos/src/routes/doorlock/logs.ts @@ -1,35 +1,12 @@ -import { - type LogsQueryInput, - logsQuerySchema, -} from '@filcdev/api/domains/doorlock/logs'; +import type { LogsQueryInput } from '@filcdev/api/domains/doorlock/logs'; +import { permissions } from '@filcdev/api/permissions'; import type { SQL } from 'drizzle-orm'; import { and, desc, eq, gte, ilike, lte, or } from 'drizzle-orm'; -import { describeRoute, resolver } from 'hono-openapi'; -import z from 'zod'; import { db } from '#database'; import { user } from '#database/schema/authentication'; import { auditLog, card, device } from '#database/schema/doorlock'; -import { authRouter } from '#middleware/auth'; -import { ok } from '#utils/http'; -import { filcExt } from '#utils/openapi'; -import { createSelectSchema } from '#utils/zod'; -import { doorlockFactory } from './_factory'; - -const auditLogSelectSchema = createSelectSchema(auditLog); -const deviceSummarySchema = createSelectSchema(device).pick({ - id: true, - name: true, -}); -const cardSummarySchema = createSelectSchema(card).pick({ - id: true, - name: true, -}); -const userSummarySchema = createSelectSchema(user).pick({ - email: true, - id: true, - name: true, - nickname: true, -}); +import { requireAuthorization } from '#middleware/auth'; +import { base } from '#orpc'; type DeviceSummary = Pick; type CardSummary = Pick; @@ -44,19 +21,6 @@ export type DoorlockLogEntry = typeof auditLog.$inferSelect & { owner?: UserSummary | null; }; -const logsResponseSchema = z.object({ - data: z.object({ - logs: z.array( - auditLogSelectSchema.extend({ - card: cardSummarySchema.nullable().optional(), - device: deviceSummarySchema.nullable().optional(), - owner: userSummarySchema.nullable().optional(), - }) - ), - }), - success: z.literal(true), -}); - const appendBaseFilters = (filters: SQL[], query: LogsQueryInput) => { if (query.cardId) { filters.push(eq(auditLog.cardId, query.cardId)); @@ -160,32 +124,9 @@ const mapRowsToLogs = ( userId: row.userId, })); -export const listLogsRoute = doorlockFactory.createHandlers( - describeRoute({ - ...filcExt( - 'Doorlock', - '@unit DoorlockLogListResponse @field(.logs, List)', - true - ), - description: 'List audit log entries', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(logsResponseSchema), - }, - }, - description: 'Successful response', - }, - }, - tags: ['Doorlock'], - }), - ...authRouter('doorlock:logs:read'), - async (c) => { - const url = new URL(c.req.url); - const queryParams = Object.fromEntries(url.searchParams.entries()); - const query = logsQuerySchema.parse(queryParams); - +export const listLogs = base.doorlock.logs.list + .use(requireAuthorization(permissions.doorlockLogsRead)) + .handler(async ({ input: query }) => { const rows = await db .select({ buttonPressed: auditLog.buttonPressed, @@ -215,6 +156,5 @@ export const listLogsRoute = doorlockFactory.createHandlers( const logs = mapRowsToLogs(rows); - return ok(c, { logs }); - } -); + return { logs }; + }); diff --git a/apps/chronos/src/routes/doorlock/ota.ts b/apps/chronos/src/routes/doorlock/ota.ts index e7363c14..50ba8051 100644 --- a/apps/chronos/src/routes/doorlock/ota.ts +++ b/apps/chronos/src/routes/doorlock/ota.ts @@ -1,40 +1,19 @@ -import { idParamSchema } from '@filcdev/api/domains/doorlock/devices'; -import { otaPayloadSchema } from '@filcdev/api/domains/doorlock/ota'; -import { zValidator } from '@hono/zod-validator'; +import { permissions } from '@filcdev/api/permissions'; import { getLogger } from '@logtape/logtape'; import { eq } from 'drizzle-orm'; -import { describeRoute } from 'hono-openapi'; import { db } from '#database'; import { device as lockDevice } from '#database/schema/doorlock'; -import { authRouter } from '#middleware/auth'; -import { sendMessage } from '#routes/doorlock/websocket-handler'; -import { notFound, ok } from '#utils/http'; -import { doorlockFactory } from './_factory'; +import { requireAuthorization } from '#middleware/auth'; +import { base } from '#orpc'; +import { sendMessage } from '#routes/doorlock/device-socket'; +import { notFound } from '#utils/http'; const logger = getLogger(['chronos', 'doorlock', 'ota']); -export const triggerDeviceOtaRoute = doorlockFactory.createHandlers( - describeRoute({ - description: 'Trigger an OTA update on a specific device', - requestBody: { - content: { - 'application/json': { - schema: { properties: { url: { type: 'string' } }, type: 'object' }, - }, - }, - }, - responses: { - 200: { description: 'OTA update triggered' }, - 404: { description: 'Device not found' }, - }, - tags: ['Doorlock'], - }), - ...authRouter('doorlock:devices:write'), - zValidator('param', idParamSchema), - zValidator('json', otaPayloadSchema), - async (c) => { - const { id } = c.req.valid('param'); - const { url } = c.req.valid('json'); +export const triggerDeviceOta = base.doorlock.devices.triggerOta + .use(requireAuthorization(permissions.doorlockDevicesWrite)) + .handler(async ({ input }) => { + const { id, url } = input; const [dev] = await db .select({ id: lockDevice.id, name: lockDevice.name }) @@ -53,29 +32,13 @@ export const triggerDeviceOtaRoute = doorlockFactory.createHandlers( sendMessage({ type: 'update', url }, dev.id); - return ok(c, undefined); - } -); + return { ok: true as const }; + }); -export const triggerBulkOtaRoute = doorlockFactory.createHandlers( - describeRoute({ - description: 'Trigger an OTA update on all devices', - requestBody: { - content: { - 'application/json': { - schema: { properties: { url: { type: 'string' } }, type: 'object' }, - }, - }, - }, - responses: { - 200: { description: 'OTA update triggered on all devices' }, - }, - tags: ['Doorlock'], - }), - ...authRouter('doorlock:devices:write'), - zValidator('json', otaPayloadSchema), - async (c) => { - const { url } = c.req.valid('json'); +export const triggerBulkOta = base.doorlock.devices.updateAll + .use(requireAuthorization(permissions.doorlockDevicesWrite)) + .handler(async ({ input }) => { + const { url } = input; const devices = await db .select({ id: lockDevice.id, name: lockDevice.name }) @@ -90,6 +53,5 @@ export const triggerBulkOtaRoute = doorlockFactory.createHandlers( sendMessage({ type: 'update', url }, dev.id); } - return ok(c, { count: devices.length }); - } -); + return { count: devices.length }; + }); diff --git a/apps/chronos/src/routes/doorlock/self.ts b/apps/chronos/src/routes/doorlock/self.ts index 9b0cf928..7e44b7af 100644 --- a/apps/chronos/src/routes/doorlock/self.ts +++ b/apps/chronos/src/routes/doorlock/self.ts @@ -1,118 +1,34 @@ -import { idParamSchema } from '@filcdev/api/domains/doorlock/devices'; -import { - activateVirtualCardSchema, - updateFrozenSchema, -} from '@filcdev/api/domains/doorlock/self'; -import { zValidator } from '@hono/zod-validator'; import { getLogger } from '@logtape/logtape'; +import { ORPCError } from '@orpc/server'; import { and, eq } from 'drizzle-orm'; -import { HTTPException } from 'hono/http-exception'; -import { describeRoute, resolver } from 'hono-openapi'; -import { StatusCodes } from 'http-status-codes'; -import z from 'zod'; import { db } from '#database'; import { auditLog, card } from '#database/schema/doorlock'; -import { authRouter } from '#middleware/auth'; -import { - cardResponseSchema, - cardsResponseSchema, -} from '#routes/doorlock/cards'; -import { sendMessage } from '#routes/doorlock/websocket-handler'; +import { requireAuthentication } from '#middleware/auth'; +import { base } from '#orpc'; +import { sendMessage } from '#routes/doorlock/device-socket'; import { fetchCardById, fetchCards } from '#utils/doorlock/cards'; import { syncDevicesByIds } from '#utils/doorlock/device-sync'; -import { notFound, ok } from '#utils/http'; -import { filcExt } from '#utils/openapi'; -import { createSelectSchema } from '#utils/zod'; -import { doorlockFactory } from './_factory'; +import { badRequest, conflict, forbidden, notFound } from '#utils/http'; const logger = getLogger(['chronos', 'doorlock', 'self']); -const auditLogSelectSchema = createSelectSchema(auditLog); - -const { schema: updateFrozenRequestSchema } = - await resolver(updateFrozenSchema).toOpenAPISchema(); - -const { schema: activateVirtualCardRequestSchema } = await resolver( - activateVirtualCardSchema -).toOpenAPISchema(); - -const activationResponseSchema = z.object({ - data: z.object({ - log: auditLogSelectSchema, - }), - success: z.literal(true), -}); - -export const listSelfCardsRoute = doorlockFactory.createHandlers( - describeRoute({ - ...filcExt( - 'Doorlock', - '@unit CardListResponse @field(.cards, List)', - true - ), - description: 'List cards owned by the authenticated user', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(cardsResponseSchema), - }, - }, - description: 'Successful response', - }, - }, - tags: ['Doorlock'], - }), - ...authRouter(), - async (c) => { - const session = c.var.session; - if (!session) { - throw new HTTPException(StatusCodes.UNAUTHORIZED); - } +export const listSelfCards = base.doorlock.self.cards.list + .use(requireAuthentication) + .handler(async ({ context }) => { + const cards = await fetchCards(eq(card.userId, context.session.userId)); - const cards = await fetchCards(eq(card.userId, session.userId)); - - return ok(c, { cards }); - } -); - -export const updateSelfCardFrozenRoute = doorlockFactory.createHandlers( - describeRoute({ - ...filcExt('Doorlock', '@unit CardResponse @field(.card, Card)', true), - description: 'Update the frozen state of a user-owned card', - requestBody: { - content: { - 'application/json': { - schema: updateFrozenRequestSchema, - }, - }, - }, - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(cardResponseSchema), - }, - }, - description: 'Card updated', - }, - 403: { description: 'Forbidden' }, - 404: { description: 'Card not found' }, - }, - tags: ['Doorlock'], - }), - ...authRouter(), - zValidator('json', updateFrozenSchema), - zValidator('param', idParamSchema), - async (c) => { - const session = c.var.session; - const { id: cardId } = c.req.valid('param'); - const payload = c.req.valid('json'); + return { cards }; + }); + +export const updateSelfCardFrozen = base.doorlock.self.cards.setFrozen + .use(requireAuthentication) + .handler(async ({ context, input }) => { + const { id: cardId, frozen } = input; const [updated] = await db .update(card) - .set({ frozen: payload.frozen }) - .where(and(eq(card.id, cardId), eq(card.userId, session.userId))) + .set({ frozen }) + .where(and(eq(card.id, cardId), eq(card.userId, context.session.userId))) .returning({ id: card.id }); if (!updated) { @@ -121,96 +37,56 @@ export const updateSelfCardFrozenRoute = doorlockFactory.createHandlers( const updatedCard = await fetchCardById(cardId); - if (!updatedCard || updatedCard.userId !== session.userId) { + if (!updatedCard || updatedCard.userId !== context.session.userId) { logger.warn('Unexpected card fetch after self-update', { cardId, - userId: session.userId, + userId: context.session.userId, }); throw notFound('Card not found'); } await syncDevicesByIds( - updatedCard.authorizedDevices.map((device) => device.id) + updatedCard.authorizedDevices.map( + (authorizedDevice) => authorizedDevice.id + ) ); - return ok(c, { card: updatedCard }); - } -); - -export const activateVirtualCardRoute = doorlockFactory.createHandlers( - describeRoute({ - ...filcExt( - 'Doorlock', - '@unit DoorlockActivationResponse @field(.log, AuditLog)', - true - ), - description: - 'Activate an authorized device using a user-owned virtual card', - requestBody: { - content: { - 'application/json': { - schema: activateVirtualCardRequestSchema, - }, - }, - }, - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(activationResponseSchema), - }, - }, - description: 'Door activation has been triggered', - }, - 400: { description: 'Missing required parameters' }, - 403: { description: 'Forbidden' }, - 404: { description: 'Card not found' }, - 409: { description: 'Card cannot be activated' }, - }, - tags: ['Doorlock'], - }), - ...authRouter(), - zValidator('json', activateVirtualCardSchema), - zValidator('param', idParamSchema), - async (c) => { - const session = c.var.session; - const { id: cardId } = c.req.valid('param'); - const payload = c.req.valid('json'); + return { card: updatedCard }; + }); + +export const activateVirtualCard = base.doorlock.self.cards.activate + .use(requireAuthentication) + .handler(async ({ context, input }) => { + const { deviceId, id: cardId } = input; const cardRecord = await fetchCardById(cardId); - if (!cardRecord || cardRecord.userId !== session.userId) { + if (!cardRecord || cardRecord.userId !== context.session.userId) { throw notFound('Card not found'); } if (!cardRecord.enabled || cardRecord.frozen) { - throw new HTTPException(StatusCodes.CONFLICT, { - message: 'Card is currently inactive', - }); + throw conflict('Card is currently inactive'); } if (!cardRecord.authorizedDevices.length) { - throw new HTTPException(StatusCodes.CONFLICT, { - message: 'Card is not authorized on any devices', - }); + throw conflict('Card is not authorized on any devices'); } const resolveTargetDevice = () => { - if (payload.deviceId) { + if (deviceId) { const matched = cardRecord.authorizedDevices.find( - (device) => device.id === payload.deviceId + (authorizedDevice) => authorizedDevice.id === deviceId ); if (!matched) { - throw new HTTPException(StatusCodes.FORBIDDEN, { - message: 'Card cannot control the requested device', - }); + throw forbidden('Card cannot control the requested device'); } return matched; } if (cardRecord.authorizedDevices.length > 1) { - throw new HTTPException(StatusCodes.BAD_REQUEST, { - message: 'deviceId is required for cards linked to multiple devices', - }); + throw badRequest( + 'deviceId is required for cards linked to multiple devices' + ); } return cardRecord.authorizedDevices[0]; @@ -241,10 +117,15 @@ export const activateVirtualCardRoute = doorlockFactory.createHandlers( cardId: cardRecord.id, deviceId: targetDevice.id, result: true, - userId: session.userId, + userId: context.session.userId, }) .returning(); - return ok(c, { log: logEntry }); - } -); + if (!logEntry) { + throw new ORPCError('INTERNAL', { + message: 'Failed to record the door activation', + }); + } + + return { log: logEntry }; + }); diff --git a/apps/chronos/src/routes/doorlock/stats.ts b/apps/chronos/src/routes/doorlock/stats.ts index 96dee143..870a791f 100644 --- a/apps/chronos/src/routes/doorlock/stats.ts +++ b/apps/chronos/src/routes/doorlock/stats.ts @@ -1,12 +1,9 @@ -import { idParamSchema } from '@filcdev/api/domains/doorlock/devices'; -import { - type DoorlockStatsOverview, - deviceStatsResponseSchema, - statsResponseSchema, +import type { + DeviceHealthStat, + DoorlockStatsOverview, } from '@filcdev/api/domains/doorlock/stats'; -import { zValidator } from '@hono/zod-validator'; +import { permissions } from '@filcdev/api/permissions'; import { and, count, desc, eq, gte, sql } from 'drizzle-orm'; -import { describeRoute, resolver } from 'hono-openapi'; import { db } from '#database'; import { user } from '#database/schema/authentication'; import { @@ -15,10 +12,8 @@ import { device, deviceHealth, } from '#database/schema/doorlock'; -import { authRouter } from '#middleware/auth'; -import { ok } from '#utils/http'; -import { filcExt } from '#utils/openapi'; -import { doorlockFactory } from './_factory'; +import { requireAuthorization } from '#middleware/auth'; +import { base } from '#orpc'; const sevenDaysAgo = () => { const date = new Date(); @@ -26,28 +21,9 @@ const sevenDaysAgo = () => { return date; }; -export const doorlockStatsRoute = doorlockFactory.createHandlers( - describeRoute({ - ...filcExt( - 'Doorlock', - '@unit DoorlockStatsResponse @field(.stats, DoorlockStats)', - true - ), - description: 'Get aggregated doorlock statistics', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(statsResponseSchema), - }, - }, - description: 'Successful response', - }, - }, - tags: ['Doorlock'], - }), - ...authRouter('doorlock:stats:read'), - async (c) => { +export const doorlockStats = base.doorlock.stats.overview + .use(requireAuthorization(permissions.doorlockStatsRead)) + .handler(async () => { const cardCount = count(card.id); const deviceCount = count(device.id); const successCount = count(auditLog.id); @@ -116,34 +92,13 @@ export const doorlockStatsRoute = doorlockFactory.createHandlers( totalSuccessfulOpens: Number(totalSuccessfulOpensRow[0]?.count ?? 0), }; - return ok(c, { stats }); - } -); + return { stats }; + }); -export const deviceStatsRoute = doorlockFactory.createHandlers( - describeRoute({ - ...filcExt( - 'Doorlock', - '@unit DeviceStatsResponse @field(.stats, List)', - true - ), - description: 'Get device health statistics', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(deviceStatsResponseSchema), - }, - }, - description: 'Successful response', - }, - }, - tags: ['Doorlock'], - }), - ...authRouter('doorlock:stats:read'), - zValidator('param', idParamSchema), - async (c) => { - const { id: deviceId } = c.req.valid('param'); +export const deviceStats = base.doorlock.devices.stats + .use(requireAuthorization(permissions.doorlockStatsRead)) + .handler(async ({ input }) => { + const deviceId = input.id; const stats = await db .select({ @@ -157,7 +112,7 @@ export const deviceStatsRoute = doorlockFactory.createHandlers( .limit(100); // map bigint to number for JSON serialization - const formattedStats = stats.map((stat) => ({ + const formattedStats: DeviceHealthStat[] = stats.map((stat) => ({ ...stat, deviceMeta: { ...stat.deviceMeta, @@ -170,6 +125,5 @@ export const deviceStatsRoute = doorlockFactory.createHandlers( }, })); - return ok(c, formattedStats); - } -); + return formattedStats; + }); diff --git a/apps/chronos/src/routes/health/_factory.ts b/apps/chronos/src/routes/health/_factory.ts deleted file mode 100644 index a8928769..00000000 --- a/apps/chronos/src/routes/health/_factory.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { createFactory } from 'hono/factory'; -import type { Context } from '#_types/globals'; - -export const healthFactory = createFactory(); diff --git a/apps/chronos/src/routes/health/_router.ts b/apps/chronos/src/routes/health/_router.ts index b77b8052..0f6a1eb1 100644 --- a/apps/chronos/src/routes/health/_router.ts +++ b/apps/chronos/src/routes/health/_router.ts @@ -1,4 +1,3 @@ -import { healthFactory } from '#routes/health/_factory'; import { health } from '#routes/health/index'; -export const healthRouter = healthFactory.createApp().get('/', ...health); +export const healthRouter = { health }; diff --git a/apps/chronos/src/routes/health/index.ts b/apps/chronos/src/routes/health/index.ts index 8b8b3749..37328213 100644 --- a/apps/chronos/src/routes/health/index.ts +++ b/apps/chronos/src/routes/health/index.ts @@ -1,39 +1,14 @@ -import { healthResponseSchema } from '@filcdev/api/domains/health'; import { sql } from 'drizzle-orm'; -import { describeRoute, resolver } from 'hono-openapi'; -import { StatusCodes } from 'http-status-codes'; import { db } from '#database'; -import { healthFactory } from '#routes/health/_factory'; -import { ApiHttpError, ok } from '#utils/http'; -import { filcExt } from '#utils/openapi'; +import { base } from '#orpc'; +import { serviceUnavailable } from '#utils/http'; -export const health = healthFactory.createHandlers( - describeRoute({ - ...filcExt('Health', '@unit HealthResponse'), - description: - 'Readiness probe: answers 200 only when PostgreSQL is reachable.', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(healthResponseSchema), - }, - }, - description: 'Database reachable', - }, - 503: { description: 'Database unreachable' }, - }, - tags: ['Health'], - }), - async (c) => { - try { - await db.execute(sql`SELECT 1`); - } catch (error) { - throw new ApiHttpError(StatusCodes.SERVICE_UNAVAILABLE, { - cause: error, - message: 'Database unavailable', - }); - } - return ok(c, { database: 'up' as const, status: 'ok' as const }); +export const health = base.health.health.handler(async () => { + try { + await db.execute(sql`SELECT 1`); + } catch (error) { + throw serviceUnavailable('Database unavailable', error); } -); + + return { database: 'up' as const, status: 'ok' as const }; +}); diff --git a/apps/chronos/src/routes/kiosk/_factory.ts b/apps/chronos/src/routes/kiosk/_factory.ts deleted file mode 100644 index ef898405..00000000 --- a/apps/chronos/src/routes/kiosk/_factory.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { createFactory } from 'hono/factory'; -import type { Context } from '#_types/globals'; - -export const kioskFactory = createFactory(); diff --git a/apps/chronos/src/routes/kiosk/_router.ts b/apps/chronos/src/routes/kiosk/_router.ts index 90f1f5f0..69df448d 100644 --- a/apps/chronos/src/routes/kiosk/_router.ts +++ b/apps/chronos/src/routes/kiosk/_router.ts @@ -1,28 +1,24 @@ -import { kioskFactory } from '#routes/kiosk/_factory'; -import { kioskDeparturesRoute } from '#routes/kiosk/departures'; -import { kioskHeartbeatRoute } from '#routes/kiosk/heartbeat'; +import { departures } from '#routes/kiosk/departures'; +import { heartbeat } from '#routes/kiosk/heartbeat'; import { - createKioskRoute, - deleteKioskRoute, - listKiosksRoute, - updateKioskRoute, + createKiosk, + deleteKiosk, + listKiosks, + updateKiosk, } from '#routes/kiosk/index'; -import { kioskNewsRoute } from '#routes/kiosk/news'; -import { kioskNewsImageRoute } from '#routes/kiosk/news-image'; -import { kioskPetrikNewsRoute } from '#routes/kiosk/petrik-news'; -import { kioskWeatherRoute } from '#routes/kiosk/weather'; +import { listNews } from '#routes/kiosk/news'; +import { newsImage } from '#routes/kiosk/news-image'; +import { petrikNews } from '#routes/kiosk/petrik-news'; +import { weather } from '#routes/kiosk/weather'; -export const kioskRouter = kioskFactory - .createApp() - // Public kiosk data - .post('/heartbeat', ...kioskHeartbeatRoute) - .get('/news', ...kioskNewsRoute) - .get('/news/:id/image', ...kioskNewsImageRoute) - .get('/petrik-news', ...kioskPetrikNewsRoute) - .get('/weather', ...kioskWeatherRoute) - .post('/departures', ...kioskDeparturesRoute) - // Kiosk registry - .get('/', ...listKiosksRoute) - .post('/', ...createKioskRoute) - .put('/:id', ...updateKioskRoute) - .delete('/:id', ...deleteKioskRoute); +export const kioskRouter = { + create: createKiosk, + delete: deleteKiosk, + departures, + heartbeat, + list: listKiosks, + news: { image: newsImage, list: listNews }, + petrikNews, + update: updateKiosk, + weather, +}; diff --git a/apps/chronos/src/routes/kiosk/departures.ts b/apps/chronos/src/routes/kiosk/departures.ts index d6403ab5..68f0b7cd 100644 --- a/apps/chronos/src/routes/kiosk/departures.ts +++ b/apps/chronos/src/routes/kiosk/departures.ts @@ -1,12 +1,6 @@ -import { kioskDeparturesRequestSchema } from '@filcdev/api/domains/kiosk/config'; -import { zValidator } from '@hono/zod-validator'; -import { describeRoute, resolver } from 'hono-openapi'; -import { StatusCodes } from 'http-status-codes'; +import { base } from '#orpc'; import { env } from '#utils/environment'; -import { ApiHttpError, ok } from '#utils/http'; -import { kioskDeparturesResponseSchema } from '#utils/kiosk/schemas'; -import { filcExt } from '#utils/openapi'; -import { kioskFactory } from './_factory'; +import { badGateway } from '#utils/http'; const BKK_URL = 'https://futar.bkk.hu/api/query/v1/ws/otp/api/where/arrivals-and-departures-for-stop'; @@ -39,10 +33,6 @@ const departuresCache = new Map< { expiresAt: number; value: Departure | null } >(); -const { schema: departuresRequestSchema } = await resolver( - kioskDeparturesRequestSchema -).toOpenAPISchema(); - type BkkReferences = NonNullable['references']; const toDeparture = ( @@ -155,61 +145,29 @@ async function getDeparturesForStop( return value; } -export const kioskDeparturesRoute = kioskFactory.createHandlers( - describeRoute({ - ...filcExt( - 'Kiosk', - '@unit KioskDeparturesResponse @field(.departures, List)' - ), - description: - 'Next departure per configured group, trying each stop in order', - requestBody: { - content: { - 'application/json': { - schema: departuresRequestSchema, - }, - }, - }, - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(kioskDeparturesResponseSchema), - }, - }, - description: 'Successful Response', - }, - 502: { description: 'BKK Futár unavailable or unconfigured' }, - }, - tags: ['Kiosk'], - }), - zValidator('json', kioskDeparturesRequestSchema), - async (c) => { - if (!env.bkkApiKey) { - throw new ApiHttpError(StatusCodes.BAD_GATEWAY, { - message: 'BKK API key is not configured', - }); - } +export const departures = base.kiosk.departures.handler(async ({ input }) => { + if (!env.bkkApiKey) { + throw badGateway('BKK API key is not configured'); + } - const { groups } = c.req.valid('json'); + const { groups } = input; - const departures = await Promise.all( - groups.map(async (group) => { - for (const stop of group.stops) { - const departure = await getDeparturesForStop( - stop.stopId, - stop.routeFilter - ); + const next = await Promise.all( + groups.map(async (group) => { + for (const stop of group.stops) { + const departure = await getDeparturesForStop( + stop.stopId, + stop.routeFilter + ); - if (departure) { - return { label: group.label, ...departure }; - } + if (departure) { + return { label: group.label, ...departure }; } + } - return null; - }) - ); + return null; + }) + ); - return ok(c, { departures }); - } -); + return { departures: next }; +}); diff --git a/apps/chronos/src/routes/kiosk/heartbeat.ts b/apps/chronos/src/routes/kiosk/heartbeat.ts index 618e0e00..52cce4fa 100644 --- a/apps/chronos/src/routes/kiosk/heartbeat.ts +++ b/apps/chronos/src/routes/kiosk/heartbeat.ts @@ -1,46 +1,12 @@ -import { kioskHeartbeatRequestSchema } from '@filcdev/api/domains/kiosk/heartbeat'; -import { zValidator } from '@hono/zod-validator'; +import { kioskKindSchema } from '@filcdev/api/domains/kiosk/config'; import { eq } from 'drizzle-orm'; -import { getConnInfo } from 'hono/bun'; -import { describeRoute, resolver } from 'hono-openapi'; import { db } from '#database'; import { kiosk } from '#database/schema/kiosk'; -import { ok } from '#utils/http'; -import { kioskHeartbeatResponseSchema } from '#utils/kiosk/schemas'; -import { filcExt } from '#utils/openapi'; -import { kioskFactory } from './_factory'; +import { base } from '#orpc'; -const { schema: heartbeatRequestSchema } = await resolver( - kioskHeartbeatRequestSchema -).toOpenAPISchema(); - -export const kioskHeartbeatRoute = kioskFactory.createHandlers( - describeRoute({ - ...filcExt('Kiosk', '@unit KioskHeartbeatResponse @field(.kiosk, Kiosk)'), - description: - 'Record a kiosk heartbeat and report whether the box is registered and enabled', - requestBody: { - content: { - 'application/json': { - schema: heartbeatRequestSchema, - }, - }, - }, - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(kioskHeartbeatResponseSchema), - }, - }, - description: 'Heartbeat recorded', - }, - }, - tags: ['Kiosk'], - }), - zValidator('json', kioskHeartbeatRequestSchema), - async (c) => { - const { appVersion, machineId } = c.req.valid('json'); +export const heartbeat = base.kiosk.heartbeat.handler( + async ({ context, input }) => { + const { appVersion, machineId } = input; const [existing] = await db .select() @@ -48,12 +14,11 @@ export const kioskHeartbeatRoute = kioskFactory.createHandlers( .where(eq(kiosk.machineId, machineId)); if (!existing) { - return ok(c, { registered: false }); + return { registered: false }; } - const forwardedFor = c.req.header('x-forwarded-for'); - const lastSeenIp = - forwardedFor?.split(',')[0]?.trim() || getConnInfo(c).remote.address; + const forwardedFor = context.reqHeaders.get('x-forwarded-for'); + const lastSeenIp = forwardedFor?.split(',')[0]?.trim() || context.clientIp; await db .update(kiosk) @@ -64,23 +29,27 @@ export const kioskHeartbeatRoute = kioskFactory.createHandlers( }) .where(eq(kiosk.id, existing.id)); + // Every write validates `kind` against this enum, but the column is plain + // text, so the contract's enum type comes from re-validating it here. + const kind = kioskKindSchema.parse(existing.kind); + if (!existing.enabled) { - return ok(c, { - enabled: false, - kiosk: { id: existing.id, kind: existing.kind, name: existing.name }, - registered: true, - }); + return { + enabled: false as const, + kiosk: { id: existing.id, kind, name: existing.name }, + registered: true as const, + }; } - return ok(c, { - enabled: true, + return { + enabled: true as const, kiosk: { config: existing.config, id: existing.id, - kind: existing.kind, + kind, name: existing.name, }, - registered: true, - }); + registered: true as const, + }; } ); diff --git a/apps/chronos/src/routes/kiosk/index.ts b/apps/chronos/src/routes/kiosk/index.ts index 4052f258..fcfa3f75 100644 --- a/apps/chronos/src/routes/kiosk/index.ts +++ b/apps/chronos/src/routes/kiosk/index.ts @@ -3,39 +3,20 @@ import { navigatorKioskConfigSchema, tvKioskConfigSchema, } from '@filcdev/api/domains/kiosk/config'; -import { - createKioskSchema, - kioskIdParamsSchema, - updateKioskSchema, -} from '@filcdev/api/domains/kiosk/crud'; import { permissions } from '@filcdev/api/permissions'; -import { zValidator } from '@hono/zod-validator'; import { getLogger } from '@logtape/logtape'; import { asc, eq } from 'drizzle-orm'; -import { HTTPException } from 'hono/http-exception'; -import { describeRoute, resolver } from 'hono-openapi'; -import { StatusCodes } from 'http-status-codes'; import { db } from '#database'; import { kiosk } from '#database/schema/kiosk'; -import { authRouter } from '#middleware/auth'; -import { badRequest, conflict, created, notFound, ok } from '#utils/http'; -import { - kioskListResponseSchema, - kioskResponseSchema, -} from '#utils/kiosk/schemas'; -import { filcExt } from '#utils/openapi'; -import { kioskFactory } from './_factory'; +import { requireAuthorization } from '#middleware/auth'; +import { base } from '#orpc'; +import { badRequest, conflict, notFound } from '#utils/http'; const logger = getLogger(['chronos', 'kiosk']); const MACHINE_ID_CONSTRAINT = 'kiosk_machine_id_unique'; const UNIQUE_VIOLATION = '23505'; -const { schema: createRequestSchema } = - await resolver(createKioskSchema).toOpenAPISchema(); -const { schema: updateRequestSchema } = - await resolver(updateKioskSchema).toOpenAPISchema(); - /** * The discriminator and the config blob arrive as separate fields, so the blob * is validated against the kind here. A missing config is validated as an @@ -78,117 +59,52 @@ function isMachineIdConflict(error: unknown): boolean { return false; } -export const listKiosksRoute = kioskFactory.createHandlers( - describeRoute({ - ...filcExt( - 'Kiosk', - '@unit KioskListResponse @field(.kiosks, List)', - true - ), - description: 'List all kiosks', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(kioskListResponseSchema), - }, - }, - description: 'Successful Response', - }, - }, - tags: ['Kiosk'], - }), - ...authRouter(permissions.kiosksManage), - async (c) => { +export const listKiosks = base.kiosk.list + .use(requireAuthorization(permissions.kiosksManage)) + .handler(async () => { const kiosks = await db.select().from(kiosk).orderBy(asc(kiosk.name)); - return ok(c, { kiosks }); - } -); + return { kiosks }; + }); -export const createKioskRoute = kioskFactory.createHandlers( - describeRoute({ - ...filcExt('Kiosk', '@unit KioskResponse @field(.kiosk, Kiosk)', true), - description: 'Register a kiosk', - requestBody: { - content: { - 'application/json': { - schema: createRequestSchema, - }, - }, - }, - responses: { - 201: { - content: { - 'application/json': { - schema: resolver(kioskResponseSchema), - }, - }, - description: 'Kiosk created', - }, - }, - tags: ['Kiosk'], - }), - ...authRouter(permissions.kiosksManage), - zValidator('json', createKioskSchema), - async (c) => { - const payload = c.req.valid('json'); - const config = parseKioskConfig(payload.kind, payload.config); +export const createKiosk = base.kiosk.create + .use(requireAuthorization(permissions.kiosksManage)) + .handler(async ({ errors, input }) => { + const config = parseKioskConfig(input.kind, input.config); + let inserted: typeof kiosk.$inferSelect | undefined; try { - const [inserted] = await db + [inserted] = await db .insert(kiosk) .values({ config, - kind: payload.kind, - machineId: payload.machineId, - name: payload.name, + kind: input.kind, + machineId: input.machineId, + name: input.name, }) .returning(); - - return created(c, { kiosk: inserted }); } catch (error) { if (isMachineIdConflict(error)) { throw conflict('A kiosk with this machine id already exists.'); } logger.error('Failed to create kiosk', { error }); - throw new HTTPException(StatusCodes.INTERNAL_SERVER_ERROR, { + throw errors.INTERNAL({ + cause: error, message: 'Failed to create kiosk', }); } - } -); -export const updateKioskRoute = kioskFactory.createHandlers( - describeRoute({ - ...filcExt('Kiosk', '@unit KioskResponse @field(.kiosk, Kiosk)', true), - description: 'Update a kiosk', - requestBody: { - content: { - 'application/json': { - schema: updateRequestSchema, - }, - }, - }, - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(kioskResponseSchema), - }, - }, - description: 'Kiosk updated', - }, - 404: { description: 'Kiosk not found' }, - }, - tags: ['Kiosk'], - }), - ...authRouter(permissions.kiosksManage), - zValidator('param', kioskIdParamsSchema), - zValidator('json', updateKioskSchema), - async (c) => { - const { id } = c.req.valid('param'); - const payload = c.req.valid('json'); + if (!inserted) { + throw errors.INTERNAL({ message: 'Failed to create kiosk' }); + } + + return { kiosk: inserted }; + }); + +export const updateKiosk = base.kiosk.update + .use(requireAuthorization(permissions.kiosksManage)) + .handler(async ({ input }) => { + const { id, ...payload } = input; const [existing] = await db.select().from(kiosk).where(eq(kiosk.id, id)); @@ -207,7 +123,7 @@ export const updateKioskRoute = kioskFactory.createHandlers( }; if (Object.keys(changes).length === 0) { - return ok(c, { kiosk: existing }); + return { kiosk: existing }; } const [updated] = await db @@ -220,31 +136,13 @@ export const updateKioskRoute = kioskFactory.createHandlers( throw notFound('Kiosk not found'); } - return ok(c, { kiosk: updated }); - } -); + return { kiosk: updated }; + }); -export const deleteKioskRoute = kioskFactory.createHandlers( - describeRoute({ - ...filcExt('Kiosk', '@unit KioskResponse @field(.kiosk, Kiosk)', true), - description: 'Delete a kiosk', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(kioskResponseSchema), - }, - }, - description: 'Kiosk deleted', - }, - 404: { description: 'Kiosk not found' }, - }, - tags: ['Kiosk'], - }), - ...authRouter(permissions.kiosksManage), - zValidator('param', kioskIdParamsSchema), - async (c) => { - const { id } = c.req.valid('param'); +export const deleteKiosk = base.kiosk.delete + .use(requireAuthorization(permissions.kiosksManage)) + .handler(async ({ input }) => { + const { id } = input; const [deleted] = await db .delete(kiosk) @@ -255,6 +153,5 @@ export const deleteKioskRoute = kioskFactory.createHandlers( throw notFound('Kiosk not found'); } - return ok(c, { kiosk: deleted }); - } -); + return { kiosk: deleted }; + }); diff --git a/apps/chronos/src/routes/kiosk/news-image.ts b/apps/chronos/src/routes/kiosk/news-image.ts index 1861ab8c..e7a7baff 100644 --- a/apps/chronos/src/routes/kiosk/news-image.ts +++ b/apps/chronos/src/routes/kiosk/news-image.ts @@ -1,36 +1,13 @@ -import { zValidator } from '@hono/zod-validator'; import { eq } from 'drizzle-orm'; -import { describeRoute } from 'hono-openapi'; -import { StatusCodes } from 'http-status-codes'; -import z from 'zod'; import { db } from '#database'; import { announcement } from '#database/schema/news'; -import { ApiHttpError, notFound } from '#utils/http'; -import { filcExt } from '#utils/openapi'; +import { base } from '#orpc'; +import { notFound, serviceUnavailable } from '#utils/http'; import { getObjectFile, isObjectStorageConfigured } from '#utils/storage/s3'; -import { kioskFactory } from './_factory'; -export const kioskNewsImageRoute = kioskFactory.createHandlers( - describeRoute({ - ...filcExt('Kiosk', '@unit KioskNewsImage'), - description: 'The image an announcement shows on the kiosk, as stored', - responses: { - 200: { - content: { - 'image/*': { - schema: { format: 'binary', type: 'string' }, - }, - }, - description: 'Announcement image', - }, - 404: { description: 'Image not found' }, - 503: { description: 'Object storage is not configured' }, - }, - tags: ['Kiosk'], - }), - zValidator('param', z.object({ id: z.uuid() })), - async (c) => { - const { id } = c.req.valid('param'); +export const newsImage = base.kiosk.news.image.handler( + async ({ context, input }) => { + const { id } = input; const [item] = await db .select({ @@ -45,18 +22,19 @@ export const kioskNewsImageRoute = kioskFactory.createHandlers( } if (!isObjectStorageConfigured()) { - throw new ApiHttpError(StatusCodes.SERVICE_UNAVAILABLE, { - message: 'Object storage is not configured', - }); + throw serviceUnavailable('Object storage is not configured'); } // Streamed through Chronos rather than redirecting to a presigned URL: the // kiosk only ever has to reach the API, and the object store need not be // exposed to the boxes. The kiosk appends `?v=` to the URL, // which is what makes a replacement visible despite the long max-age. - return c.body(getObjectFile(item.imageKey).stream(), StatusCodes.OK, { - 'Cache-Control': 'public, max-age=86400', - 'Content-Type': item.imageContentType, - }); + context.resHeaders?.set('Cache-Control', 'public, max-age=86400'); + + return new File( + [await getObjectFile(item.imageKey).arrayBuffer()], + item.imageKey.split('/').pop() ?? id, + { type: item.imageContentType } + ); } ); diff --git a/apps/chronos/src/routes/kiosk/news.ts b/apps/chronos/src/routes/kiosk/news.ts index fd847262..4a83a634 100644 --- a/apps/chronos/src/routes/kiosk/news.ts +++ b/apps/chronos/src/routes/kiosk/news.ts @@ -1,94 +1,76 @@ import { and, asc, isNotNull, sql } from 'drizzle-orm'; -import { describeRoute, resolver } from 'hono-openapi'; import { db } from '#database'; import { announcement, announcementCohortMtm, announcementKioskMtm, } from '#database/schema/news'; -import { ok } from '#utils/http'; -import { kioskNewsResponseSchema } from '#utils/kiosk/schemas'; +import { base } from '#orpc'; import { activeAnnouncementConditions, flattenAnnouncementContent, } from '#utils/news/announcements'; -import { filcExt } from '#utils/openapi'; -import { kioskFactory } from './_factory'; -export const kioskNewsRoute = kioskFactory.createHandlers( - describeRoute({ - ...filcExt( - 'Kiosk', - '@unit KioskNewsResponse @field(.announcements, List)' - ), - description: - 'Active, titled, non-cohort-scoped announcements for the TV kiosk marquee', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(kioskNewsResponseSchema), - }, - }, - description: 'Successful Response', - }, - }, - tags: ['Kiosk'], - }), - async (c) => { - const now = new Date(); +export const listNews = base.kiosk.news.list.handler(async () => { + const now = new Date(); - const announcements = await db - .select({ - body: announcement.content, - highlighted: announcement.highlighted, - id: announcement.id, - imageUpdatedAt: announcement.imageUpdatedAt, - title: announcement.title, - validFrom: announcement.validFrom, - validUntil: announcement.validUntil, - }) - .from(announcement) - .where( - and( - isNotNull(announcement.title), - sql`NOT EXISTS ( + const announcements = await db + .select({ + body: announcement.content, + highlighted: announcement.highlighted, + id: announcement.id, + imageUpdatedAt: announcement.imageUpdatedAt, + title: announcement.title, + validFrom: announcement.validFrom, + validUntil: announcement.validUntil, + }) + .from(announcement) + .where( + and( + isNotNull(announcement.title), + sql`NOT EXISTS ( SELECT 1 FROM ${announcementCohortMtm} WHERE ${announcementCohortMtm.announcementId} = ${announcement.id} )`, - ...activeAnnouncementConditions(now) - ) + ...activeAnnouncementConditions(now) ) - .orderBy(asc(announcement.validUntil)) - .limit(10); + ) + .orderBy(asc(announcement.validUntil)) + .limit(10); - // Which boxes each takeover is meant for; a box with no rows for an item - // shows it too, so an untargeted announcement stays on every screen. - const itemIds = announcements.map((item) => item.id); - const kioskMappings = - itemIds.length > 0 - ? await db - .select() - .from(announcementKioskMtm) - .where(sql`${announcementKioskMtm.announcementId} IN ${itemIds}`) - : []; + // Which boxes each takeover is meant for; a box with no rows for an item + // shows it too, so an untargeted announcement stays on every screen. + const itemIds = announcements.map((item) => item.id); + const kioskMappings = + itemIds.length > 0 + ? await db + .select() + .from(announcementKioskMtm) + .where(sql`${announcementKioskMtm.announcementId} IN ${itemIds}`) + : []; - // The takeover shows the same items as the marquee, so the feed carries - // what it takes to render them full screen: the flattened text and a - // version token for the image URL. Order stays `validUntil asc`. - return ok(c, { - announcements: announcements.map((item) => ({ - body: flattenAnnouncementContent(item.body), - highlighted: item.highlighted, - id: item.id, - imageVersion: item.imageUpdatedAt?.toISOString() ?? null, - kioskIds: kioskMappings - .filter((m) => m.announcementId === item.id) - .map((m) => m.kioskId), - title: item.title, - validFrom: item.validFrom, - validUntil: item.validUntil, - })), - }); - } -); + // The takeover shows the same items as the marquee, so the feed carries + // what it takes to render them full screen: the flattened text and a + // version token for the image URL. Order stays `validUntil asc`. + // `title` is nullable in the table but the query above only matches titled + // announcements; the filter makes that visible to the type system. + const titled = announcements.filter( + (item): item is (typeof announcements)[number] & { title: string } => + item.title !== null + ); + + return { + announcements: titled.map((item) => ({ + body: flattenAnnouncementContent(item.body), + highlighted: item.highlighted, + id: item.id, + imageVersion: item.imageUpdatedAt?.toISOString() ?? null, + kioskIds: kioskMappings + .filter((m) => m.announcementId === item.id) + .map((m) => m.kioskId), + title: item.title, + validFrom: item.validFrom, + validUntil: item.validUntil, + })), + }; +}); diff --git a/apps/chronos/src/routes/kiosk/petrik-news.ts b/apps/chronos/src/routes/kiosk/petrik-news.ts index 6c07cf2d..c3015891 100644 --- a/apps/chronos/src/routes/kiosk/petrik-news.ts +++ b/apps/chronos/src/routes/kiosk/petrik-news.ts @@ -3,14 +3,12 @@ import { DEFAULT_PETRIK_NEWS_MAX_ITEMS, navigatorKioskConfigSchema, } from '@filcdev/api/domains/kiosk/config'; -import { zValidator } from '@hono/zod-validator'; +import { ORPCError } from '@orpc/server'; import { eq } from 'drizzle-orm'; -import { describeRoute, resolver } from 'hono-openapi'; -import { StatusCodes } from 'http-status-codes'; -import z from 'zod'; import { db } from '#database'; import { kiosk } from '#database/schema/kiosk'; -import { ApiHttpError, ok } from '#utils/http'; +import { base } from '#orpc'; +import { badGateway } from '#utils/http'; import { assertAllowedFeedUrl } from '#utils/kiosk/feed-url'; import { type PetrikNewsItem, @@ -18,19 +16,12 @@ import { parsePetrikNewsFeed, stripTrailingSlash, } from '#utils/kiosk/petrik-news'; -import { kioskPetrikNewsResponseSchema } from '#utils/kiosk/schemas'; -import { filcExt } from '#utils/openapi'; -import { kioskFactory } from './_factory'; const PETRIK_NEWS_CACHE_TTL_MS = 10 * 60 * 1000; const PETRIK_NEWS_TIMEOUT_MS = 10_000; const MAX_FEED_BYTES = 1_048_576; const MAX_JSON_BYTES = 4 * 1024 * 1024; -const petrikNewsQuerySchema = z.object({ - machine: z.string().min(1).optional(), -}); - /** * Per-URL cache: each resolved feed URL has its own ten-minute TTL. The feed * URL is resolved from the kiosk's own stored config, never from the client, @@ -144,89 +135,66 @@ async function resolveFeed(machine: string | undefined): Promise { return { enabled, feedUrl, maxItems }; } -export const kioskPetrikNewsRoute = kioskFactory.createHandlers( - describeRoute({ - ...filcExt('Kiosk', '@unit KioskPetrikNewsResponse'), - description: - 'petrik.hu news for the navigator kiosk, cached for ten minutes', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(kioskPetrikNewsResponseSchema), - }, - }, - description: 'Successful Response', - }, - 502: { description: 'petrik.hu feed unavailable' }, - }, - tags: ['Kiosk'], - }), - zValidator('query', petrikNewsQuerySchema), - async (c) => { - const { machine } = c.req.valid('query'); - const feed = await resolveFeed(machine); - - if (!feed.enabled) { - return ok(c, { items: [] }); - } +export const petrikNews = base.kiosk.petrikNews.handler(async ({ input }) => { + const { machine } = input; + const feed = await resolveFeed(machine); - // Serve the cache without a DNS round-trip; only a miss reaches the SSRF - // guard, so a warm cache never pays for `assertAllowedFeedUrl`. - const cached = feedCache.get(feed.feedUrl); - if (cached && cached.expiresAt > Date.now()) { - return ok(c, { items: cached.items.slice(0, feed.maxItems) }); - } + if (!feed.enabled) { + return { items: [] }; + } - await assertAllowedFeedUrl(feed.feedUrl); + // Serve the cache without a DNS round-trip; only a miss reaches the SSRF + // guard, so a warm cache never pays for `assertAllowedFeedUrl`. + const cached = feedCache.get(feed.feedUrl); + if (cached && cached.expiresAt > Date.now()) { + return { items: cached.items.slice(0, feed.maxItems) }; + } - let xml: string; - try { - const response = await fetch(feed.feedUrl, { - headers: { 'user-agent': 'filc-kiosk/1.0 (+https://filc.petrik.hu)' }, - redirect: 'manual', - signal: AbortSignal.timeout(PETRIK_NEWS_TIMEOUT_MS), - }); - - if (!response.ok) { - throw new Error(`petrik.hu responded with ${response.status}`); - } + await assertAllowedFeedUrl(feed.feedUrl); - xml = await readCappedText(response, MAX_FEED_BYTES); - } catch (error) { - if (error instanceof ApiHttpError) { - throw error; - } - throw new ApiHttpError(StatusCodes.BAD_GATEWAY, { - cause: error, - message: 'Failed to fetch petrik.hu news', - }); + let xml: string; + try { + const response = await fetch(feed.feedUrl, { + headers: { 'user-agent': 'filc-kiosk/1.0 (+https://filc.petrik.hu)' }, + redirect: 'manual', + signal: AbortSignal.timeout(PETRIK_NEWS_TIMEOUT_MS), + }); + + if (!response.ok) { + throw new Error(`petrik.hu responded with ${response.status}`); } - const items = parsePetrikNewsFeed(xml); - - // petrik.hu's feed never exposes the featured image, so resolve the - // WordPress REST featured map and apply it as the primary picture; a post - // with no featured image (featured_media: 0) keeps its first in-body - // `` as the fallback. This is best-effort: a failed lookup leaves the - // parsed items (and their in-body images) untouched. - if (items.length > 0) { - try { - const featured = await fetchFeaturedImages(feed.feedUrl); - for (const item of items) { - item.imageUrl = - featured.get(stripTrailingSlash(item.url)) ?? item.imageUrl; - } - } catch { - // Best-effort fallback; keep the parsed items as-is on any failure. - } + xml = await readCappedText(response, MAX_FEED_BYTES); + } catch (error) { + if (error instanceof ORPCError) { + throw error; } + throw badGateway('Failed to fetch petrik.hu news', error); + } - feedCache.set(feed.feedUrl, { - expiresAt: Date.now() + PETRIK_NEWS_CACHE_TTL_MS, - items, - }); + const items = parsePetrikNewsFeed(xml); - return ok(c, { items: items.slice(0, feed.maxItems) }); + // petrik.hu's feed never exposes the featured image, so resolve the + // WordPress REST featured map and apply it as the primary picture; a post + // with no featured image (featured_media: 0) keeps its first in-body + // `` as the fallback. This is best-effort: a failed lookup leaves the + // parsed items (and their in-body images) untouched. + if (items.length > 0) { + try { + const featured = await fetchFeaturedImages(feed.feedUrl); + for (const item of items) { + item.imageUrl = + featured.get(stripTrailingSlash(item.url)) ?? item.imageUrl; + } + } catch { + // Best-effort fallback; keep the parsed items as-is on any failure. + } } -); + + feedCache.set(feed.feedUrl, { + expiresAt: Date.now() + PETRIK_NEWS_CACHE_TTL_MS, + items, + }); + + return { items: items.slice(0, feed.maxItems) }; +}); diff --git a/apps/chronos/src/routes/kiosk/weather.ts b/apps/chronos/src/routes/kiosk/weather.ts index 2da37cb3..45e1fac6 100644 --- a/apps/chronos/src/routes/kiosk/weather.ts +++ b/apps/chronos/src/routes/kiosk/weather.ts @@ -1,10 +1,6 @@ -import { describeRoute, resolver } from 'hono-openapi'; -import { StatusCodes } from 'http-status-codes'; +import { base } from '#orpc'; import { env } from '#utils/environment'; -import { ApiHttpError, ok } from '#utils/http'; -import { kioskWeatherResponseSchema } from '#utils/kiosk/schemas'; -import { filcExt } from '#utils/openapi'; -import { kioskFactory } from './_factory'; +import { badGateway } from '#utils/http'; const WEATHER_URL = 'https://api.weatherapi.com/v1/current.json'; const WEATHER_CACHE_TTL_MS = 10 * 60 * 1000; @@ -28,69 +24,46 @@ type KioskWeather = { let cachedWeather: { expiresAt: number; value: KioskWeather } | null = null; -export const kioskWeatherRoute = kioskFactory.createHandlers( - describeRoute({ - ...filcExt('Kiosk', '@unit KioskWeatherResponse'), - description: 'Current weather for the TV kiosk, cached for ten minutes', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(kioskWeatherResponseSchema), - }, - }, - description: 'Successful Response', - }, - 502: { description: 'Weather provider unavailable or unconfigured' }, - }, - tags: ['Kiosk'], - }), - async (c) => { - if (!env.weatherApiKey) { - throw new ApiHttpError(StatusCodes.BAD_GATEWAY, { - message: 'Weather API key is not configured', - }); - } - - if (cachedWeather && cachedWeather.expiresAt > Date.now()) { - return ok(c, cachedWeather.value); - } +export const weather = base.kiosk.weather.handler(async () => { + if (!env.weatherApiKey) { + throw badGateway('Weather API key is not configured'); + } - const url = new URL(WEATHER_URL); - url.searchParams.set('key', env.weatherApiKey); - url.searchParams.set('q', env.kioskWeatherLocation); - url.searchParams.set('lang', 'hu'); + if (cachedWeather && cachedWeather.expiresAt > Date.now()) { + return cachedWeather.value; + } - let payload: WeatherResponse; - try { - const response = await fetch(url, { - signal: AbortSignal.timeout(WEATHER_TIMEOUT_MS), - }); + const url = new URL(WEATHER_URL); + url.searchParams.set('key', env.weatherApiKey); + url.searchParams.set('q', env.kioskWeatherLocation); + url.searchParams.set('lang', 'hu'); - if (!response.ok) { - throw new Error(`Weather API responded with ${response.status}`); - } + let payload: WeatherResponse; + try { + const response = await fetch(url, { + signal: AbortSignal.timeout(WEATHER_TIMEOUT_MS), + }); - payload = (await response.json()) as WeatherResponse; - } catch (error) { - throw new ApiHttpError(StatusCodes.BAD_GATEWAY, { - cause: error, - message: 'Failed to fetch weather', - }); + if (!response.ok) { + throw new Error(`Weather API responded with ${response.status}`); } - const value = { - icon: payload.current.condition.icon, - precipMm: payload.current.precip_mm, - tempC: Math.round(payload.current.temp_c), - windKph: payload.current.wind_kph, - }; + payload = (await response.json()) as WeatherResponse; + } catch (error) { + throw badGateway('Failed to fetch weather', error); + } - cachedWeather = { - expiresAt: Date.now() + WEATHER_CACHE_TTL_MS, - value, - }; + const value = { + icon: payload.current.condition.icon, + precipMm: payload.current.precip_mm, + tempC: Math.round(payload.current.temp_c), + windKph: payload.current.wind_kph, + }; - return ok(c, value); - } -); + cachedWeather = { + expiresAt: Date.now() + WEATHER_CACHE_TTL_MS, + value, + }; + + return value; +}); diff --git a/apps/chronos/src/routes/navigator/_factory.ts b/apps/chronos/src/routes/navigator/_factory.ts deleted file mode 100644 index 536c63bf..00000000 --- a/apps/chronos/src/routes/navigator/_factory.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { createFactory } from 'hono/factory'; -import type { Context } from '#_types/globals'; - -export const navigatorFactory = createFactory(); diff --git a/apps/chronos/src/routes/navigator/_router.ts b/apps/chronos/src/routes/navigator/_router.ts index 04e3a1c2..4ceeb374 100644 --- a/apps/chronos/src/routes/navigator/_router.ts +++ b/apps/chronos/src/routes/navigator/_router.ts @@ -1,4 +1,3 @@ -import { navigatorFactory } from '#routes/navigator/_factory'; import { createBuildingRoute, deleteBuildingRoute, @@ -49,46 +48,52 @@ import { updateTranslationRoute, } from '#routes/navigator/translations'; -export const navigatorRouter = navigatorFactory - .createApp() - .get('/graph', ...graphRoute) - // Transfer (export/import) - .get('/export', ...exportNavigatorRoute) - .post('/import', ...importNavigatorRoute) - // Buildings - .get('/buildings', ...listBuildingsRoute) - .post('/buildings', ...createBuildingRoute) - .put('/buildings/:id', ...updateBuildingRoute) - .delete('/buildings/:id', ...deleteBuildingRoute) - // Classroom types - .get('/classroom-types', ...listClassroomTypesRoute) - .post('/classroom-types', ...createClassroomTypeRoute) - .put('/classroom-types/:id', ...updateClassroomTypeRoute) - .delete('/classroom-types/:id', ...deleteClassroomTypeRoute) - // Classrooms - .get('/classrooms', ...listClassroomsRoute) - .post('/classrooms', ...createClassroomRoute) - .put('/classrooms/:id', ...updateClassroomRoute) - .delete('/classrooms/:id', ...deleteClassroomRoute) - // Corridors - .get('/corridors', ...listCorridorsRoute) - .post('/corridors', ...createCorridorRoute) - .put('/corridors/:id', ...updateCorridorRoute) - .delete('/corridors/:id', ...deleteCorridorRoute) - // Lifts - .get('/lifts', ...listLiftsRoute) - .post('/lifts', ...createLiftRoute) - .put('/lifts/:id', ...updateLiftRoute) - .delete('/lifts/:id', ...deleteLiftRoute) - // Stairs - .get('/stairs', ...listStairsRoute) - .post('/stairs', ...createStairRoute) - .put('/stairs/:id', ...updateStairRoute) - .delete('/stairs/:id', ...deleteStairRoute) - // Translations - .get('/translations/available', ...listTranslationLanguagesRoute) - .get('/translations/lang', ...getTranslationBundleRoute) - .get('/translations', ...listTranslationsRoute) - .post('/translations', ...createTranslationRoute) - .put('/translations/:lang/:key', ...updateTranslationRoute) - .delete('/translations/:lang/:key', ...deleteTranslationRoute); +export const navigatorRouter = { + buildings: { + create: createBuildingRoute, + delete: deleteBuildingRoute, + list: listBuildingsRoute, + update: updateBuildingRoute, + }, + classrooms: { + create: createClassroomRoute, + delete: deleteClassroomRoute, + list: listClassroomsRoute, + update: updateClassroomRoute, + }, + classroomTypes: { + create: createClassroomTypeRoute, + delete: deleteClassroomTypeRoute, + list: listClassroomTypesRoute, + update: updateClassroomTypeRoute, + }, + corridors: { + create: createCorridorRoute, + delete: deleteCorridorRoute, + list: listCorridorsRoute, + update: updateCorridorRoute, + }, + export: exportNavigatorRoute, + graph: graphRoute, + import: importNavigatorRoute, + lifts: { + create: createLiftRoute, + delete: deleteLiftRoute, + list: listLiftsRoute, + update: updateLiftRoute, + }, + stairs: { + create: createStairRoute, + delete: deleteStairRoute, + list: listStairsRoute, + update: updateStairRoute, + }, + translations: { + available: listTranslationLanguagesRoute, + create: createTranslationRoute, + delete: deleteTranslationRoute, + lang: getTranslationBundleRoute, + list: listTranslationsRoute, + update: updateTranslationRoute, + }, +}; diff --git a/apps/chronos/src/routes/navigator/buildings.ts b/apps/chronos/src/routes/navigator/buildings.ts index 2e75b52a..8f2610ed 100644 --- a/apps/chronos/src/routes/navigator/buildings.ts +++ b/apps/chronos/src/routes/navigator/buildings.ts @@ -1,126 +1,45 @@ -import { - createBuildingSchema, - updateBuildingSchema, -} from '@filcdev/api/domains/navigator/building'; -import { navigatorIdParamsSchema } from '@filcdev/api/domains/navigator/params'; import { permissions } from '@filcdev/api/permissions'; -import { zValidator } from '@hono/zod-validator'; +import { ORPCError } from '@orpc/server'; import { asc, eq } from 'drizzle-orm'; -import { describeRoute, resolver } from 'hono-openapi'; import { db } from '#database'; import { building as buildingTable } from '#database/schema/timetable'; -import { authRouter } from '#middleware/auth'; -import { navigatorFactory } from '#routes/navigator/_factory'; -import { conflict, created, notFound, ok } from '#utils/http'; +import { requireAuthorization } from '#middleware/auth'; +import { base } from '#orpc'; +import { conflict, notFound } from '#utils/http'; import { isReferencedRowError } from '#utils/navigator/errors'; -import { - buildingResponseSchema, - buildingsResponseSchema, -} from '#utils/navigator/schemas'; -import { filcExt } from '#utils/openapi'; -const { schema: createBuildingRequestSchema } = - await resolver(createBuildingSchema).toOpenAPISchema(); -const { schema: updateBuildingRequestSchema } = - await resolver(updateBuildingSchema).toOpenAPISchema(); - -export const listBuildingsRoute = navigatorFactory.createHandlers( - describeRoute({ - ...filcExt('Navigator', '@listof Building'), - description: 'List the campus buildings', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(buildingsResponseSchema), - }, - }, - description: 'Successful Response', - }, - }, - tags: ['Navigator'], - }), - async (c) => { +export const listBuildingsRoute = base.navigator.buildings.list.handler( + async () => { const buildings = await db .select() .from(buildingTable) .orderBy(asc(buildingTable.name)); - return ok(c, { buildings }); + return { buildings }; } ); -export const createBuildingRoute = navigatorFactory.createHandlers( - describeRoute({ - ...filcExt('Navigator', '@unit Building', true), - description: 'Create a campus building', - requestBody: { - content: { - 'application/json': { - schema: createBuildingRequestSchema, - }, - }, - }, - responses: { - 201: { - content: { - 'application/json': { - schema: resolver(buildingResponseSchema), - }, - }, - description: 'Building created', - }, - 401: { description: 'Unauthorized' }, - 403: { description: 'Forbidden' }, - }, - tags: ['Navigator'], - }), - ...authRouter(permissions.navigatorManage), - zValidator('json', createBuildingSchema), - async (c) => { - const payload = c.req.valid('json'); - +export const createBuildingRoute = base.navigator.buildings.create + .use(requireAuthorization(permissions.navigatorManage)) + .handler(async ({ input }) => { const [building] = await db .insert(buildingTable) - .values({ id: crypto.randomUUID(), mapped: true, ...payload }) + .values({ id: crypto.randomUUID(), mapped: true, ...input }) .returning(); - return created(c, { building }); - } -); + if (!building) { + throw new ORPCError('INTERNAL', { + message: 'Failed to create building', + }); + } -export const updateBuildingRoute = navigatorFactory.createHandlers( - describeRoute({ - ...filcExt('Navigator', '@unit Building', true), - description: 'Update a campus building', - requestBody: { - content: { - 'application/json': { - schema: updateBuildingRequestSchema, - }, - }, - }, - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(buildingResponseSchema), - }, - }, - description: 'Building updated', - }, - 401: { description: 'Unauthorized' }, - 403: { description: 'Forbidden' }, - 404: { description: 'Building not found' }, - }, - tags: ['Navigator'], - }), - ...authRouter(permissions.navigatorManage), - zValidator('json', updateBuildingSchema), - zValidator('param', navigatorIdParamsSchema), - async (c) => { - const { id } = c.req.valid('param'); - const payload = c.req.valid('json'); + return { building }; + }); + +export const updateBuildingRoute = base.navigator.buildings.update + .use(requireAuthorization(permissions.navigatorManage)) + .handler(async ({ input }) => { + const { id, ...payload } = input; // An empty patch is a legal (if pointless) request, and Drizzle refuses to // build an `update … set` with no values. A campus save is an admin @@ -138,35 +57,13 @@ export const updateBuildingRoute = navigatorFactory.createHandlers( throw notFound('Building not found'); } - return ok(c, { building }); - } -); + return { building }; + }); -export const deleteBuildingRoute = navigatorFactory.createHandlers( - describeRoute({ - ...filcExt('Navigator', '@unit Building', true), - description: - 'Delete a campus building; its corridors, lifts and stairs cascade with it, but rooms still assigned to it refuse the delete', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(buildingResponseSchema), - }, - }, - description: 'Building deleted', - }, - 401: { description: 'Unauthorized' }, - 403: { description: 'Forbidden' }, - 404: { description: 'Building not found' }, - 409: { description: 'Building still has rooms' }, - }, - tags: ['Navigator'], - }), - ...authRouter(permissions.navigatorManage), - zValidator('param', navigatorIdParamsSchema), - async (c) => { - const { id } = c.req.valid('param'); +export const deleteBuildingRoute = base.navigator.buildings.delete + .use(requireAuthorization(permissions.navigatorManage)) + .handler(async ({ input }) => { + const { id } = input; try { const [building] = await db @@ -178,7 +75,7 @@ export const deleteBuildingRoute = navigatorFactory.createHandlers( throw notFound('Building not found'); } - return ok(c, { building }); + return { building }; } catch (error) { // The foreign key is what decides whether the building still has rooms: // translating its error avoids a pre-check query that would race a @@ -188,5 +85,4 @@ export const deleteBuildingRoute = navigatorFactory.createHandlers( } throw error; } - } -); + }); diff --git a/apps/chronos/src/routes/navigator/classroom-types.ts b/apps/chronos/src/routes/navigator/classroom-types.ts index db7db4c5..68bd5e71 100644 --- a/apps/chronos/src/routes/navigator/classroom-types.ts +++ b/apps/chronos/src/routes/navigator/classroom-types.ts @@ -1,128 +1,44 @@ -import { - createClassroomTypeSchema, - updateClassroomTypeSchema, -} from '@filcdev/api/domains/navigator/classroom-type'; -import { navigatorIdParamsSchema } from '@filcdev/api/domains/navigator/params'; import { permissions } from '@filcdev/api/permissions'; -import { zValidator } from '@hono/zod-validator'; +import { ORPCError } from '@orpc/server'; import { asc, eq } from 'drizzle-orm'; -import { describeRoute, resolver } from 'hono-openapi'; import { db } from '#database'; import { classroomType as classroomTypeTable } from '#database/schema/timetable'; -import { authRouter } from '#middleware/auth'; -import { navigatorFactory } from '#routes/navigator/_factory'; -import { conflict, created, notFound, ok } from '#utils/http'; +import { requireAuthorization } from '#middleware/auth'; +import { base } from '#orpc'; +import { conflict, notFound } from '#utils/http'; import { isReferencedRowError } from '#utils/navigator/errors'; -import { - classroomTypeResponseSchema, - classroomTypesResponseSchema, -} from '#utils/navigator/schemas'; -import { filcExt } from '#utils/openapi'; -const { schema: createClassroomTypeRequestSchema } = await resolver( - createClassroomTypeSchema -).toOpenAPISchema(); -const { schema: updateClassroomTypeRequestSchema } = await resolver( - updateClassroomTypeSchema -).toOpenAPISchema(); - -export const listClassroomTypesRoute = navigatorFactory.createHandlers( - describeRoute({ - ...filcExt('Navigator', '@listof ClassroomType'), - description: 'List the classroom types', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(classroomTypesResponseSchema), - }, - }, - description: 'Successful Response', - }, - }, - tags: ['Navigator'], - }), - async (c) => { +export const listClassroomTypesRoute = + base.navigator.classroomTypes.list.handler(async () => { const classroomTypes = await db .select() .from(classroomTypeTable) .orderBy(asc(classroomTypeTable.name)); - return ok(c, { classroom_types: classroomTypes }); - } -); - -export const createClassroomTypeRoute = navigatorFactory.createHandlers( - describeRoute({ - ...filcExt('Navigator', '@unit ClassroomType', true), - description: 'Create a classroom type', - requestBody: { - content: { - 'application/json': { - schema: createClassroomTypeRequestSchema, - }, - }, - }, - responses: { - 201: { - content: { - 'application/json': { - schema: resolver(classroomTypeResponseSchema), - }, - }, - description: 'Classroom type created', - }, - 401: { description: 'Unauthorized' }, - 403: { description: 'Forbidden' }, - }, - tags: ['Navigator'], - }), - ...authRouter(permissions.navigatorManage), - zValidator('json', createClassroomTypeSchema), - async (c) => { - const payload = c.req.valid('json'); + return { classroom_types: classroomTypes }; + }); +export const createClassroomTypeRoute = base.navigator.classroomTypes.create + .use(requireAuthorization(permissions.navigatorManage)) + .handler(async ({ input }) => { const [classroomType] = await db .insert(classroomTypeTable) - .values({ id: crypto.randomUUID(), ...payload }) + .values({ id: crypto.randomUUID(), ...input }) .returning(); - return created(c, { classroom_type: classroomType }); - } -); + if (!classroomType) { + throw new ORPCError('INTERNAL', { + message: 'Failed to create classroom type', + }); + } + + return { classroom_type: classroomType }; + }); -export const updateClassroomTypeRoute = navigatorFactory.createHandlers( - describeRoute({ - ...filcExt('Navigator', '@unit ClassroomType', true), - description: 'Update a classroom type', - requestBody: { - content: { - 'application/json': { - schema: updateClassroomTypeRequestSchema, - }, - }, - }, - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(classroomTypeResponseSchema), - }, - }, - description: 'Classroom type updated', - }, - 401: { description: 'Unauthorized' }, - 403: { description: 'Forbidden' }, - 404: { description: 'Classroom type not found' }, - }, - tags: ['Navigator'], - }), - ...authRouter(permissions.navigatorManage), - zValidator('json', updateClassroomTypeSchema), - zValidator('param', navigatorIdParamsSchema), - async (c) => { - const { id } = c.req.valid('param'); - const payload = c.req.valid('json'); +export const updateClassroomTypeRoute = base.navigator.classroomTypes.update + .use(requireAuthorization(permissions.navigatorManage)) + .handler(async ({ input }) => { + const { id, ...payload } = input; // An empty patch is a legal (if pointless) request, and Drizzle refuses to // build an `update … set` with no values. @@ -142,35 +58,13 @@ export const updateClassroomTypeRoute = navigatorFactory.createHandlers( throw notFound('Classroom type not found'); } - return ok(c, { classroom_type: classroomType }); - } -); + return { classroom_type: classroomType }; + }); -export const deleteClassroomTypeRoute = navigatorFactory.createHandlers( - describeRoute({ - ...filcExt('Navigator', '@unit ClassroomType', true), - description: - 'Delete a classroom type; types still assigned to a classroom are refused', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(classroomTypeResponseSchema), - }, - }, - description: 'Classroom type deleted', - }, - 401: { description: 'Unauthorized' }, - 403: { description: 'Forbidden' }, - 404: { description: 'Classroom type not found' }, - 409: { description: 'Classroom type is still used by a classroom' }, - }, - tags: ['Navigator'], - }), - ...authRouter(permissions.navigatorManage), - zValidator('param', navigatorIdParamsSchema), - async (c) => { - const { id } = c.req.valid('param'); +export const deleteClassroomTypeRoute = base.navigator.classroomTypes.delete + .use(requireAuthorization(permissions.navigatorManage)) + .handler(async ({ input }) => { + const { id } = input; try { const [classroomType] = await db @@ -182,15 +76,16 @@ export const deleteClassroomTypeRoute = navigatorFactory.createHandlers( throw notFound('Classroom type not found'); } - return ok(c, { classroom_type: classroomType }); + return { classroom_type: classroomType }; } catch (error) { - // The FK is RESTRICT, so the database is the authority on whether the - // type is still referenced: translating its error avoids a pre-check - // query that would race a concurrent insert. + // `classroom.type_id` restricts the delete, so the FK error is what says + // a classroom still uses this type. if (isReferencedRowError(error)) { - throw conflict('Classroom type is still used by a classroom', error); + throw conflict( + 'Classrooms still use this type; reassign them first', + error + ); } throw error; } - } -); + }); diff --git a/apps/chronos/src/routes/navigator/classrooms.ts b/apps/chronos/src/routes/navigator/classrooms.ts index 422da842..70efdca9 100644 --- a/apps/chronos/src/routes/navigator/classrooms.ts +++ b/apps/chronos/src/routes/navigator/classrooms.ts @@ -1,12 +1,6 @@ -import { - createClassroomSchema, - updateClassroomSchema, -} from '@filcdev/api/domains/navigator/classroom'; -import { navigatorIdParamsSchema } from '@filcdev/api/domains/navigator/params'; import { permissions } from '@filcdev/api/permissions'; -import { zValidator } from '@hono/zod-validator'; +import { ORPCError } from '@orpc/server'; import { asc, eq, sql } from 'drizzle-orm'; -import { describeRoute, resolver } from 'hono-openapi'; import { db } from '#database'; import { classroom as classroomTable, @@ -14,120 +8,42 @@ import { lesson, movedLesson, } from '#database/schema/timetable'; -import { authRouter } from '#middleware/auth'; -import { navigatorFactory } from '#routes/navigator/_factory'; -import { conflict, created, notFound, ok } from '#utils/http'; -import { - classroomResponseSchema, - classroomsResponseSchema, -} from '#utils/navigator/schemas'; -import { filcExt } from '#utils/openapi'; +import { requireAuthorization } from '#middleware/auth'; +import { base } from '#orpc'; +import { conflict, notFound } from '#utils/http'; -const { schema: createClassroomRequestSchema } = await resolver( - createClassroomSchema -).toOpenAPISchema(); -const { schema: updateClassroomRequestSchema } = await resolver( - updateClassroomSchema -).toOpenAPISchema(); - -export const listClassroomsRoute = navigatorFactory.createHandlers( - describeRoute({ - ...filcExt('Navigator', '@listof Classroom'), - description: - 'List the classrooms, including the ones the campus has not placed yet', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(classroomsResponseSchema), - }, - }, - description: 'Successful Response', - }, - }, - tags: ['Navigator'], - }), - async (c) => { +export const listClassroomsRoute = base.navigator.classrooms.list.handler( + async () => { const classrooms = await db .select() .from(classroomTable) .orderBy(asc(classroomTable.name)); - return ok(c, { classrooms }); + return { classrooms }; } ); -export const createClassroomRoute = navigatorFactory.createHandlers( - describeRoute({ - ...filcExt('Navigator', '@unit Classroom', true), - description: 'Create a classroom', - requestBody: { - content: { - 'application/json': { - schema: createClassroomRequestSchema, - }, - }, - }, - responses: { - 201: { - content: { - 'application/json': { - schema: resolver(classroomResponseSchema), - }, - }, - description: 'Classroom created', - }, - 401: { description: 'Unauthorized' }, - 403: { description: 'Forbidden' }, - }, - tags: ['Navigator'], - }), - ...authRouter(permissions.navigatorManage), - zValidator('json', createClassroomSchema), - async (c) => { - const payload = c.req.valid('json'); - +export const createClassroomRoute = base.navigator.classrooms.create + .use(requireAuthorization(permissions.navigatorManage)) + .handler(async ({ input }) => { const [classroom] = await db .insert(classroomTable) - .values({ id: crypto.randomUUID(), mapped: true, ...payload }) + .values({ id: crypto.randomUUID(), mapped: true, ...input }) .returning(); - return created(c, { classroom }); - } -); + if (!classroom) { + throw new ORPCError('INTERNAL', { + message: 'Failed to create classroom', + }); + } + + return { classroom }; + }); -export const updateClassroomRoute = navigatorFactory.createHandlers( - describeRoute({ - ...filcExt('Navigator', '@unit Classroom', true), - description: 'Update a classroom', - requestBody: { - content: { - 'application/json': { - schema: updateClassroomRequestSchema, - }, - }, - }, - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(classroomResponseSchema), - }, - }, - description: 'Classroom updated', - }, - 401: { description: 'Unauthorized' }, - 403: { description: 'Forbidden' }, - 404: { description: 'Classroom not found' }, - }, - tags: ['Navigator'], - }), - ...authRouter(permissions.navigatorManage), - zValidator('json', updateClassroomSchema), - zValidator('param', navigatorIdParamsSchema), - async (c) => { - const { id } = c.req.valid('param'); - const payload = c.req.valid('json'); +export const updateClassroomRoute = base.navigator.classrooms.update + .use(requireAuthorization(permissions.navigatorManage)) + .handler(async ({ input }) => { + const { id, ...payload } = input; // An empty patch is a legal (if pointless) request, and Drizzle refuses to // build an `update … set` with no values. A campus save is an admin @@ -148,35 +64,13 @@ export const updateClassroomRoute = navigatorFactory.createHandlers( throw notFound('Classroom not found'); } - return ok(c, { classroom }); - } -); + return { classroom }; + }); -export const deleteClassroomRoute = navigatorFactory.createHandlers( - describeRoute({ - ...filcExt('Navigator', '@unit Classroom', true), - description: - 'Delete a classroom; rooms the timetable still uses are refused', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(classroomResponseSchema), - }, - }, - description: 'Classroom deleted', - }, - 401: { description: 'Unauthorized' }, - 403: { description: 'Forbidden' }, - 404: { description: 'Classroom not found' }, - 409: { description: 'The timetable still uses the classroom' }, - }, - tags: ['Navigator'], - }), - ...authRouter(permissions.navigatorManage), - zValidator('param', navigatorIdParamsSchema), - async (c) => { - const { id } = c.req.valid('param'); +export const deleteClassroomRoute = base.navigator.classrooms.delete + .use(requireAuthorization(permissions.navigatorManage)) + .handler(async ({ input }) => { + const { id } = input; // The room rows are the timetable's too, and nothing in the timetable // cascades: refuse the delete while any of its own rows still points here, @@ -218,6 +112,5 @@ export const deleteClassroomRoute = navigatorFactory.createHandlers( throw notFound('Classroom not found'); } - return ok(c, { classroom }); - } -); + return { classroom }; + }); diff --git a/apps/chronos/src/routes/navigator/corridors.ts b/apps/chronos/src/routes/navigator/corridors.ts index b16e9822..3ce11856 100644 --- a/apps/chronos/src/routes/navigator/corridors.ts +++ b/apps/chronos/src/routes/navigator/corridors.ts @@ -1,125 +1,44 @@ -import { - createCorridorSchema, - updateCorridorSchema, -} from '@filcdev/api/domains/navigator/corridor'; -import { navigatorIdParamsSchema } from '@filcdev/api/domains/navigator/params'; import { permissions } from '@filcdev/api/permissions'; -import { zValidator } from '@hono/zod-validator'; +import { ORPCError } from '@orpc/server'; import { asc, eq } from 'drizzle-orm'; -import { describeRoute, resolver } from 'hono-openapi'; import { db } from '#database'; import { navigatorCorridor } from '#database/schema/navigator'; -import { authRouter } from '#middleware/auth'; -import { navigatorFactory } from '#routes/navigator/_factory'; -import { created, notFound, ok } from '#utils/http'; -import { - corridorResponseSchema, - corridorsResponseSchema, -} from '#utils/navigator/schemas'; -import { filcExt } from '#utils/openapi'; +import { requireAuthorization } from '#middleware/auth'; +import { base } from '#orpc'; +import { notFound } from '#utils/http'; -const { schema: createCorridorRequestSchema } = - await resolver(createCorridorSchema).toOpenAPISchema(); -const { schema: updateCorridorRequestSchema } = - await resolver(updateCorridorSchema).toOpenAPISchema(); - -export const listCorridorsRoute = navigatorFactory.createHandlers( - describeRoute({ - ...filcExt('Navigator', '@listof Corridor'), - description: 'List the corridors', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(corridorsResponseSchema), - }, - }, - description: 'Successful Response', - }, - }, - tags: ['Navigator'], - }), - async (c) => { +export const listCorridorsRoute = base.navigator.corridors.list.handler( + async () => { const corridors = await db .select() .from(navigatorCorridor) .orderBy(asc(navigatorCorridor.name)); - return ok(c, { corridors }); + return { corridors }; } ); -export const createCorridorRoute = navigatorFactory.createHandlers( - describeRoute({ - ...filcExt('Navigator', '@unit Corridor', true), - description: 'Create a corridor', - requestBody: { - content: { - 'application/json': { - schema: createCorridorRequestSchema, - }, - }, - }, - responses: { - 201: { - content: { - 'application/json': { - schema: resolver(corridorResponseSchema), - }, - }, - description: 'Corridor created', - }, - 401: { description: 'Unauthorized' }, - 403: { description: 'Forbidden' }, - }, - tags: ['Navigator'], - }), - ...authRouter(permissions.navigatorManage), - zValidator('json', createCorridorSchema), - async (c) => { - const payload = c.req.valid('json'); - +export const createCorridorRoute = base.navigator.corridors.create + .use(requireAuthorization(permissions.navigatorManage)) + .handler(async ({ input }) => { const [corridor] = await db .insert(navigatorCorridor) - .values({ id: crypto.randomUUID(), ...payload }) + .values({ id: crypto.randomUUID(), ...input }) .returning(); - return created(c, { corridor }); - } -); + if (!corridor) { + throw new ORPCError('INTERNAL', { + message: 'Failed to create corridor', + }); + } -export const updateCorridorRoute = navigatorFactory.createHandlers( - describeRoute({ - ...filcExt('Navigator', '@unit Corridor', true), - description: 'Update a corridor', - requestBody: { - content: { - 'application/json': { - schema: updateCorridorRequestSchema, - }, - }, - }, - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(corridorResponseSchema), - }, - }, - description: 'Corridor updated', - }, - 401: { description: 'Unauthorized' }, - 403: { description: 'Forbidden' }, - 404: { description: 'Corridor not found' }, - }, - tags: ['Navigator'], - }), - ...authRouter(permissions.navigatorManage), - zValidator('json', updateCorridorSchema), - zValidator('param', navigatorIdParamsSchema), - async (c) => { - const { id } = c.req.valid('param'); - const payload = c.req.valid('json'); + return { corridor }; + }); + +export const updateCorridorRoute = base.navigator.corridors.update + .use(requireAuthorization(permissions.navigatorManage)) + .handler(async ({ input }) => { + const { id, ...payload } = input; // An empty patch is a legal (if pointless) request, and Drizzle refuses to // build an `update … set` with no values. @@ -139,33 +58,13 @@ export const updateCorridorRoute = navigatorFactory.createHandlers( throw notFound('Corridor not found'); } - return ok(c, { corridor }); - } -); + return { corridor }; + }); -export const deleteCorridorRoute = navigatorFactory.createHandlers( - describeRoute({ - ...filcExt('Navigator', '@unit Corridor', true), - description: 'Delete a corridor', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(corridorResponseSchema), - }, - }, - description: 'Corridor deleted', - }, - 401: { description: 'Unauthorized' }, - 403: { description: 'Forbidden' }, - 404: { description: 'Corridor not found' }, - }, - tags: ['Navigator'], - }), - ...authRouter(permissions.navigatorManage), - zValidator('param', navigatorIdParamsSchema), - async (c) => { - const { id } = c.req.valid('param'); +export const deleteCorridorRoute = base.navigator.corridors.delete + .use(requireAuthorization(permissions.navigatorManage)) + .handler(async ({ input }) => { + const { id } = input; const [corridor] = await db .delete(navigatorCorridor) @@ -176,6 +75,5 @@ export const deleteCorridorRoute = navigatorFactory.createHandlers( throw notFound('Corridor not found'); } - return ok(c, { corridor }); - } -); + return { corridor }; + }); diff --git a/apps/chronos/src/routes/navigator/graph.ts b/apps/chronos/src/routes/navigator/graph.ts index 60a5a6a5..4cfd69dc 100644 --- a/apps/chronos/src/routes/navigator/graph.ts +++ b/apps/chronos/src/routes/navigator/graph.ts @@ -1,5 +1,4 @@ import { and, asc, eq, getTableColumns } from 'drizzle-orm'; -import { describeRoute, resolver } from 'hono-openapi'; import { db } from '#database'; import { navigatorCorridor, @@ -7,77 +6,56 @@ import { navigatorStair, } from '#database/schema/navigator'; import { building, classroom, classroomType } from '#database/schema/timetable'; -import { navigatorFactory } from '#routes/navigator/_factory'; -import { ok } from '#utils/http'; -import { graphResponseSchema } from '#utils/navigator/schemas'; -import { filcExt } from '#utils/openapi'; +import { base } from '#orpc'; -export const graphRoute = navigatorFactory.createHandlers( - describeRoute({ - ...filcExt('Navigator', '@unit FullGraph'), - description: - 'The whole campus graph in the upstream wire format, without a session. Only rows the campus has placed on the map appear.', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(graphResponseSchema), - }, - }, - description: 'Successful Response', - }, - }, - tags: ['Navigator'], - }), - async (c) => { - const [buildings, classroomTypes, classrooms, corridors, lifts, stairs] = - await Promise.all([ - db - .select() - .from(building) - .where(eq(building.mapped, true)) - .orderBy(asc(building.name)), - db.select().from(classroomType).orderBy(asc(classroomType.name)), - // Rooms and structures only reach the graph through a placed building. - db - .select({ ...getTableColumns(classroom) }) - .from(classroom) - .innerJoin(building, eq(classroom.building_id, building.id)) - .where(and(eq(classroom.mapped, true), eq(building.mapped, true))) - .orderBy(asc(classroom.name)), - db - .select({ ...getTableColumns(navigatorCorridor) }) - .from(navigatorCorridor) - .innerJoin(building, eq(navigatorCorridor.building_id, building.id)) - .where(eq(building.mapped, true)) - .orderBy(asc(navigatorCorridor.name)), - db - .select({ ...getTableColumns(navigatorLift) }) - .from(navigatorLift) - .innerJoin(building, eq(navigatorLift.building_id, building.id)) - .where(eq(building.mapped, true)) - .orderBy(asc(navigatorLift.name)), - db - .select({ ...getTableColumns(navigatorStair) }) - .from(navigatorStair) - .innerJoin(building, eq(navigatorStair.building_id, building.id)) - .where(eq(building.mapped, true)) - .orderBy(asc(navigatorStair.name)), - ]); +export const graphRoute = base.navigator.graph.handler(async () => { + const [buildings, classroomTypes, classrooms, corridors, lifts, stairs] = + await Promise.all([ + db + .select() + .from(building) + .where(eq(building.mapped, true)) + .orderBy(asc(building.name)), + db.select().from(classroomType).orderBy(asc(classroomType.name)), + // Rooms and structures only reach the graph through a placed building. + db + .select({ ...getTableColumns(classroom) }) + .from(classroom) + .innerJoin(building, eq(classroom.building_id, building.id)) + .where(and(eq(classroom.mapped, true), eq(building.mapped, true))) + .orderBy(asc(classroom.name)), + db + .select({ ...getTableColumns(navigatorCorridor) }) + .from(navigatorCorridor) + .innerJoin(building, eq(navigatorCorridor.building_id, building.id)) + .where(eq(building.mapped, true)) + .orderBy(asc(navigatorCorridor.name)), + db + .select({ ...getTableColumns(navigatorLift) }) + .from(navigatorLift) + .innerJoin(building, eq(navigatorLift.building_id, building.id)) + .where(eq(building.mapped, true)) + .orderBy(asc(navigatorLift.name)), + db + .select({ ...getTableColumns(navigatorStair) }) + .from(navigatorStair) + .innerJoin(building, eq(navigatorStair.building_id, building.id)) + .where(eq(building.mapped, true)) + .orderBy(asc(navigatorStair.name)), + ]); - return ok(c, { - buildings, - classroom_types: classroomTypes, - // The graph's shape has no nullable campus fields: only placed rooms are - // here, and placing one is what fills them in. - classrooms: classrooms.map((row) => ({ - ...row, - capacity: row.capacity ?? 0, - type_id: row.type_id ?? '', - })), - corridors, - lifts, - stairs, - }); - } -); + return { + buildings, + classroom_types: classroomTypes, + // The graph's shape has no nullable campus fields: only placed rooms are + // here, and placing one is what fills them in. + classrooms: classrooms.map((row) => ({ + ...row, + capacity: row.capacity ?? 0, + type_id: row.type_id ?? '', + })), + corridors, + lifts, + stairs, + }; +}); diff --git a/apps/chronos/src/routes/navigator/lifts.ts b/apps/chronos/src/routes/navigator/lifts.ts index 7a05ca5f..d84e3ce7 100644 --- a/apps/chronos/src/routes/navigator/lifts.ts +++ b/apps/chronos/src/routes/navigator/lifts.ts @@ -1,125 +1,40 @@ -import { - createLiftSchema, - updateLiftSchema, -} from '@filcdev/api/domains/navigator/lift'; -import { navigatorIdParamsSchema } from '@filcdev/api/domains/navigator/params'; import { permissions } from '@filcdev/api/permissions'; -import { zValidator } from '@hono/zod-validator'; +import { ORPCError } from '@orpc/server'; import { asc, eq } from 'drizzle-orm'; -import { describeRoute, resolver } from 'hono-openapi'; import { db } from '#database'; import { navigatorLift } from '#database/schema/navigator'; -import { authRouter } from '#middleware/auth'; -import { navigatorFactory } from '#routes/navigator/_factory'; -import { created, notFound, ok } from '#utils/http'; -import { - liftResponseSchema, - liftsResponseSchema, -} from '#utils/navigator/schemas'; -import { filcExt } from '#utils/openapi'; +import { requireAuthorization } from '#middleware/auth'; +import { base } from '#orpc'; +import { notFound } from '#utils/http'; -const { schema: createLiftRequestSchema } = - await resolver(createLiftSchema).toOpenAPISchema(); -const { schema: updateLiftRequestSchema } = - await resolver(updateLiftSchema).toOpenAPISchema(); +export const listLiftsRoute = base.navigator.lifts.list.handler(async () => { + const lifts = await db + .select() + .from(navigatorLift) + .orderBy(asc(navigatorLift.name)); -export const listLiftsRoute = navigatorFactory.createHandlers( - describeRoute({ - ...filcExt('Navigator', '@listof Lift'), - description: 'List the lifts', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(liftsResponseSchema), - }, - }, - description: 'Successful Response', - }, - }, - tags: ['Navigator'], - }), - async (c) => { - const lifts = await db - .select() - .from(navigatorLift) - .orderBy(asc(navigatorLift.name)); - - return ok(c, { lifts }); - } -); - -export const createLiftRoute = navigatorFactory.createHandlers( - describeRoute({ - ...filcExt('Navigator', '@unit Lift', true), - description: 'Create a lift', - requestBody: { - content: { - 'application/json': { - schema: createLiftRequestSchema, - }, - }, - }, - responses: { - 201: { - content: { - 'application/json': { - schema: resolver(liftResponseSchema), - }, - }, - description: 'Lift created', - }, - 401: { description: 'Unauthorized' }, - 403: { description: 'Forbidden' }, - }, - tags: ['Navigator'], - }), - ...authRouter(permissions.navigatorManage), - zValidator('json', createLiftSchema), - async (c) => { - const payload = c.req.valid('json'); + return { lifts }; +}); +export const createLiftRoute = base.navigator.lifts.create + .use(requireAuthorization(permissions.navigatorManage)) + .handler(async ({ input }) => { const [lift] = await db .insert(navigatorLift) - .values({ id: crypto.randomUUID(), ...payload }) + .values({ id: crypto.randomUUID(), ...input }) .returning(); - return created(c, { lift }); - } -); + if (!lift) { + throw new ORPCError('INTERNAL', { message: 'Failed to create lift' }); + } + + return { lift }; + }); -export const updateLiftRoute = navigatorFactory.createHandlers( - describeRoute({ - ...filcExt('Navigator', '@unit Lift', true), - description: 'Update a lift', - requestBody: { - content: { - 'application/json': { - schema: updateLiftRequestSchema, - }, - }, - }, - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(liftResponseSchema), - }, - }, - description: 'Lift updated', - }, - 401: { description: 'Unauthorized' }, - 403: { description: 'Forbidden' }, - 404: { description: 'Lift not found' }, - }, - tags: ['Navigator'], - }), - ...authRouter(permissions.navigatorManage), - zValidator('json', updateLiftSchema), - zValidator('param', navigatorIdParamsSchema), - async (c) => { - const { id } = c.req.valid('param'); - const payload = c.req.valid('json'); +export const updateLiftRoute = base.navigator.lifts.update + .use(requireAuthorization(permissions.navigatorManage)) + .handler(async ({ input }) => { + const { id, ...payload } = input; // An empty patch is a legal (if pointless) request, and Drizzle refuses to // build an `update … set` with no values. @@ -136,33 +51,13 @@ export const updateLiftRoute = navigatorFactory.createHandlers( throw notFound('Lift not found'); } - return ok(c, { lift }); - } -); + return { lift }; + }); -export const deleteLiftRoute = navigatorFactory.createHandlers( - describeRoute({ - ...filcExt('Navigator', '@unit Lift', true), - description: 'Delete a lift', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(liftResponseSchema), - }, - }, - description: 'Lift deleted', - }, - 401: { description: 'Unauthorized' }, - 403: { description: 'Forbidden' }, - 404: { description: 'Lift not found' }, - }, - tags: ['Navigator'], - }), - ...authRouter(permissions.navigatorManage), - zValidator('param', navigatorIdParamsSchema), - async (c) => { - const { id } = c.req.valid('param'); +export const deleteLiftRoute = base.navigator.lifts.delete + .use(requireAuthorization(permissions.navigatorManage)) + .handler(async ({ input }) => { + const { id } = input; const [lift] = await db .delete(navigatorLift) @@ -173,6 +68,5 @@ export const deleteLiftRoute = navigatorFactory.createHandlers( throw notFound('Lift not found'); } - return ok(c, { lift }); - } -); + return { lift }; + }); diff --git a/apps/chronos/src/routes/navigator/stairs.ts b/apps/chronos/src/routes/navigator/stairs.ts index 1f06c468..281a3b30 100644 --- a/apps/chronos/src/routes/navigator/stairs.ts +++ b/apps/chronos/src/routes/navigator/stairs.ts @@ -1,125 +1,40 @@ -import { navigatorIdParamsSchema } from '@filcdev/api/domains/navigator/params'; -import { - createStairSchema, - updateStairSchema, -} from '@filcdev/api/domains/navigator/stair'; import { permissions } from '@filcdev/api/permissions'; -import { zValidator } from '@hono/zod-validator'; +import { ORPCError } from '@orpc/server'; import { asc, eq } from 'drizzle-orm'; -import { describeRoute, resolver } from 'hono-openapi'; import { db } from '#database'; import { navigatorStair } from '#database/schema/navigator'; -import { authRouter } from '#middleware/auth'; -import { navigatorFactory } from '#routes/navigator/_factory'; -import { created, notFound, ok } from '#utils/http'; -import { - stairResponseSchema, - stairsResponseSchema, -} from '#utils/navigator/schemas'; -import { filcExt } from '#utils/openapi'; +import { requireAuthorization } from '#middleware/auth'; +import { base } from '#orpc'; +import { notFound } from '#utils/http'; -const { schema: createStairRequestSchema } = - await resolver(createStairSchema).toOpenAPISchema(); -const { schema: updateStairRequestSchema } = - await resolver(updateStairSchema).toOpenAPISchema(); +export const listStairsRoute = base.navigator.stairs.list.handler(async () => { + const stairs = await db + .select() + .from(navigatorStair) + .orderBy(asc(navigatorStair.name)); -export const listStairsRoute = navigatorFactory.createHandlers( - describeRoute({ - ...filcExt('Navigator', '@listof Stair'), - description: 'List the staircases', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(stairsResponseSchema), - }, - }, - description: 'Successful Response', - }, - }, - tags: ['Navigator'], - }), - async (c) => { - const stairs = await db - .select() - .from(navigatorStair) - .orderBy(asc(navigatorStair.name)); - - return ok(c, { stairs }); - } -); - -export const createStairRoute = navigatorFactory.createHandlers( - describeRoute({ - ...filcExt('Navigator', '@unit Stair', true), - description: 'Create a staircase', - requestBody: { - content: { - 'application/json': { - schema: createStairRequestSchema, - }, - }, - }, - responses: { - 201: { - content: { - 'application/json': { - schema: resolver(stairResponseSchema), - }, - }, - description: 'Staircase created', - }, - 401: { description: 'Unauthorized' }, - 403: { description: 'Forbidden' }, - }, - tags: ['Navigator'], - }), - ...authRouter(permissions.navigatorManage), - zValidator('json', createStairSchema), - async (c) => { - const payload = c.req.valid('json'); + return { stairs }; +}); +export const createStairRoute = base.navigator.stairs.create + .use(requireAuthorization(permissions.navigatorManage)) + .handler(async ({ input }) => { const [stair] = await db .insert(navigatorStair) - .values({ id: crypto.randomUUID(), ...payload }) + .values({ id: crypto.randomUUID(), ...input }) .returning(); - return created(c, { stair }); - } -); + if (!stair) { + throw new ORPCError('INTERNAL', { message: 'Failed to create stair' }); + } + + return { stair }; + }); -export const updateStairRoute = navigatorFactory.createHandlers( - describeRoute({ - ...filcExt('Navigator', '@unit Stair', true), - description: 'Update a staircase', - requestBody: { - content: { - 'application/json': { - schema: updateStairRequestSchema, - }, - }, - }, - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(stairResponseSchema), - }, - }, - description: 'Staircase updated', - }, - 401: { description: 'Unauthorized' }, - 403: { description: 'Forbidden' }, - 404: { description: 'Staircase not found' }, - }, - tags: ['Navigator'], - }), - ...authRouter(permissions.navigatorManage), - zValidator('json', updateStairSchema), - zValidator('param', navigatorIdParamsSchema), - async (c) => { - const { id } = c.req.valid('param'); - const payload = c.req.valid('json'); +export const updateStairRoute = base.navigator.stairs.update + .use(requireAuthorization(permissions.navigatorManage)) + .handler(async ({ input }) => { + const { id, ...payload } = input; // An empty patch is a legal (if pointless) request, and Drizzle refuses to // build an `update … set` with no values. @@ -139,33 +54,13 @@ export const updateStairRoute = navigatorFactory.createHandlers( throw notFound('Staircase not found'); } - return ok(c, { stair }); - } -); + return { stair }; + }); -export const deleteStairRoute = navigatorFactory.createHandlers( - describeRoute({ - ...filcExt('Navigator', '@unit Stair', true), - description: 'Delete a staircase', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(stairResponseSchema), - }, - }, - description: 'Staircase deleted', - }, - 401: { description: 'Unauthorized' }, - 403: { description: 'Forbidden' }, - 404: { description: 'Staircase not found' }, - }, - tags: ['Navigator'], - }), - ...authRouter(permissions.navigatorManage), - zValidator('param', navigatorIdParamsSchema), - async (c) => { - const { id } = c.req.valid('param'); +export const deleteStairRoute = base.navigator.stairs.delete + .use(requireAuthorization(permissions.navigatorManage)) + .handler(async ({ input }) => { + const { id } = input; const [stair] = await db .delete(navigatorStair) @@ -176,6 +71,5 @@ export const deleteStairRoute = navigatorFactory.createHandlers( throw notFound('Staircase not found'); } - return ok(c, { stair }); - } -); + return { stair }; + }); diff --git a/apps/chronos/src/routes/navigator/transfer.ts b/apps/chronos/src/routes/navigator/transfer.ts index ae45066e..d6ca0fdc 100644 --- a/apps/chronos/src/routes/navigator/transfer.ts +++ b/apps/chronos/src/routes/navigator/transfer.ts @@ -1,7 +1,5 @@ import { permissions } from '@filcdev/api/permissions'; -import { zValidator } from '@hono/zod-validator'; import { asc, sql } from 'drizzle-orm'; -import { describeRoute, resolver } from 'hono-openapi'; import { db } from '#database'; import { navigatorCorridor, @@ -14,22 +12,15 @@ import { classroom as classroomTable, classroomType as classroomTypeTable, } from '#database/schema/timetable'; -import { authRouter } from '#middleware/auth'; -import { navigatorFactory } from '#routes/navigator/_factory'; -import { badRequest, ok } from '#utils/http'; +import { requireAuthorization } from '#middleware/auth'; +import { base } from '#orpc'; +import { badRequest } from '#utils/http'; import { isIntegrityViolation } from '#utils/navigator/errors'; import { type NavigatorTransfer, - navigatorImportResponseSchema, navigatorImportSchema, - navigatorTransferResponseSchema, navigatorTransferSchema, } from '#utils/navigator/transfer'; -import { filcExt } from '#utils/openapi'; - -const { schema: navigatorImportRequestSchema } = await resolver( - navigatorImportSchema -).toOpenAPISchema(); type TxOrDb = typeof db | Parameters[0]>[0]; @@ -107,27 +98,9 @@ async function upsertNavigatorOnlyTables( } } -export const exportNavigatorRoute = navigatorFactory.createHandlers( - describeRoute({ - ...filcExt('Navigator', '@unit NavigatorTransfer', true), - description: - 'Export every navigator row (buildings, classroom types, classrooms, corridors, lifts, stairs and translations) as a versioned JSON payload.', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(navigatorTransferResponseSchema), - }, - }, - description: 'Navigator export payload', - }, - 401: { description: 'Unauthorized' }, - 403: { description: 'Forbidden' }, - }, - tags: ['Navigator'], - }), - ...authRouter(permissions.navigatorManage), - async (c) => { +export const exportNavigatorRoute = base.navigator.export + .use(requireAuthorization(permissions.navigatorManage)) + .handler(async () => { // Read every collection from one repeatable-read snapshot so the export // payload cannot straddle a concurrent import/write halfway through. const payload = await db.transaction( @@ -183,41 +156,28 @@ export const exportNavigatorRoute = navigatorFactory.createHandlers( { isolationLevel: 'repeatable read' } ); - return ok(c, payload); - } -); + // The download is the file the import procedure accepts back: the same + // pretty-printed JSON the admin UI used to build client-side. + return new File( + [JSON.stringify(payload, null, 2)], + `navigator-export-${new Date().toISOString().slice(0, 10)}.json`, + { type: 'application/json' } + ); + }); -export const importNavigatorRoute = navigatorFactory.createHandlers( - describeRoute({ - ...filcExt('Navigator', '@unit NavigatorImport', true), - description: - 'Insert or update every collection in a navigator export payload by its key, inside one transaction. Rows absent from the payload are never deleted.', - requestBody: { - content: { - 'application/json': { - schema: navigatorImportRequestSchema, - }, - }, - }, - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(navigatorImportResponseSchema), - }, - }, - description: 'Import result with per-collection row counts', - }, - 400: { description: 'Invalid navigator export payload' }, - 401: { description: 'Unauthorized' }, - 403: { description: 'Forbidden' }, - }, - tags: ['Navigator'], - }), - ...authRouter(permissions.navigatorManage), - zValidator('json', navigatorImportSchema), - async (c) => { - const payload = c.req.valid('json'); +export const importNavigatorRoute = base.navigator.import + .use(requireAuthorization(permissions.navigatorManage)) + .handler(async ({ input }) => { + // The upload is a navigator export file. A body that is not JSON and a + // body that fails the transfer schema are both the caller's fault. + let payload: NavigatorTransfer; + try { + payload = navigatorImportSchema.parse( + JSON.parse(await input.file.text()) + ); + } catch (error) { + throw badRequest('Navigator import payload is invalid', error); + } try { await db.transaction(async (tx) => { @@ -291,7 +251,7 @@ export const importNavigatorRoute = navigatorFactory.createHandlers( throw err; } - return ok(c, { + return { buildings: payload.buildings.length, classrooms: payload.classrooms.length, classroomTypes: payload.classroomTypes.length, @@ -299,6 +259,5 @@ export const importNavigatorRoute = navigatorFactory.createHandlers( lifts: payload.lifts.length, stairs: payload.stairs.length, translations: payload.translations.length, - }); - } -); + }; + }); diff --git a/apps/chronos/src/routes/navigator/translations.ts b/apps/chronos/src/routes/navigator/translations.ts index 499a03ab..5cbcf7ef 100644 --- a/apps/chronos/src/routes/navigator/translations.ts +++ b/apps/chronos/src/routes/navigator/translations.ts @@ -1,79 +1,25 @@ -import { - createTranslationSchema, - langQuerySchema, - translationParamsSchema, - updateTranslationSchema, -} from '@filcdev/api/domains/navigator/translation'; import { permissions } from '@filcdev/api/permissions'; -import { zValidator } from '@hono/zod-validator'; +import { ORPCError } from '@orpc/server'; import { and, asc, eq, sql } from 'drizzle-orm'; -import { describeRoute, resolver } from 'hono-openapi'; import { db } from '#database'; import { navigatorTranslation } from '#database/schema/navigator'; -import { authRouter } from '#middleware/auth'; -import { navigatorFactory } from '#routes/navigator/_factory'; -import { badRequest, created, notFound, ok } from '#utils/http'; -import { - languagesResponseSchema, - translationBundleResponseSchema, - translationResponseSchema, - translationsResponseSchema, -} from '#utils/navigator/schemas'; -import { filcExt } from '#utils/openapi'; +import { requireAuthorization } from '#middleware/auth'; +import { base } from '#orpc'; +import { badRequest, notFound } from '#utils/http'; -const { schema: createTranslationRequestSchema } = await resolver( - createTranslationSchema -).toOpenAPISchema(); -const { schema: updateTranslationRequestSchema } = await resolver( - updateTranslationSchema -).toOpenAPISchema(); - -export const listTranslationLanguagesRoute = navigatorFactory.createHandlers( - describeRoute({ - ...filcExt('Navigator', '@listof Language'), - description: 'List the languages the campus translations exist in', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(languagesResponseSchema), - }, - }, - description: 'Successful Response', - }, - }, - tags: ['Navigator'], - }), - async (c) => { +export const listTranslationLanguagesRoute = + base.navigator.translations.available.handler(async () => { const rows = await db .selectDistinct({ lang_key: navigatorTranslation.lang_key }) .from(navigatorTranslation) .orderBy(asc(navigatorTranslation.lang_key)); - return ok(c, { languages: rows.map((row) => row.lang_key) }); - } -); + return { languages: rows.map((row) => row.lang_key) }; + }); -export const getTranslationBundleRoute = navigatorFactory.createHandlers( - describeRoute({ - ...filcExt('Navigator', 'TranslationBundle'), - description: - 'One language as a flat `text_key -> text` bundle, without a session', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(translationBundleResponseSchema), - }, - }, - description: 'Successful Response', - }, - }, - tags: ['Navigator'], - }), - zValidator('query', langQuerySchema), - async (c) => { - const { lang } = c.req.valid('query'); +export const getTranslationBundleRoute = + base.navigator.translations.lang.handler(async ({ input }) => { + const { lang } = input; const rows = await db .select({ @@ -83,31 +29,12 @@ export const getTranslationBundleRoute = navigatorFactory.createHandlers( .from(navigatorTranslation) .where(eq(navigatorTranslation.lang_key, lang)); - return ok( - c, - Object.fromEntries(rows.map((row) => [row.text_key, row.text])) - ); - } -); + return Object.fromEntries(rows.map((row) => [row.text_key, row.text])); + }); -export const listTranslationsRoute = navigatorFactory.createHandlers( - describeRoute({ - ...filcExt('Navigator', '@listof Translation', true), - description: 'List every translation row', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(translationsResponseSchema), - }, - }, - description: 'Successful Response', - }, - }, - tags: ['Navigator'], - }), - ...authRouter(permissions.navigatorManage), - async (c) => { +export const listTranslationsRoute = base.navigator.translations.list + .use(requireAuthorization(permissions.navigatorManage)) + .handler(async () => { const translations = await db .select() .from(navigatorTranslation) @@ -116,40 +43,13 @@ export const listTranslationsRoute = navigatorFactory.createHandlers( asc(navigatorTranslation.lang_key) ); - return ok(c, { translations }); - } -); + return { translations }; + }); -export const createTranslationRoute = navigatorFactory.createHandlers( - describeRoute({ - ...filcExt('Navigator', '@listof Translation', true), - description: 'Create or replace one codename across every given language', - requestBody: { - content: { - 'application/json': { - schema: createTranslationRequestSchema, - }, - }, - }, - responses: { - 201: { - content: { - 'application/json': { - schema: resolver(translationsResponseSchema), - }, - }, - description: 'Translations created', - }, - 400: { description: 'No translation given' }, - 401: { description: 'Unauthorized' }, - 403: { description: 'Forbidden' }, - }, - tags: ['Navigator'], - }), - ...authRouter(permissions.navigatorManage), - zValidator('json', createTranslationSchema), - async (c) => { - const { text_key, translations } = c.req.valid('json'); +export const createTranslationRoute = base.navigator.translations.create + .use(requireAuthorization(permissions.navigatorManage)) + .handler(async ({ input }) => { + const { text_key, translations } = input; const values = Object.entries(translations).map(([lang_key, text]) => ({ lang_key, @@ -172,41 +72,13 @@ export const createTranslationRoute = navigatorFactory.createHandlers( }) .returning(); - return created(c, { translations: rows }); - } -); + return { translations: rows }; + }); -export const updateTranslationRoute = navigatorFactory.createHandlers( - describeRoute({ - ...filcExt('Navigator', '@unit Translation', true), - description: 'Write the text of one codename in one language', - requestBody: { - content: { - 'application/json': { - schema: updateTranslationRequestSchema, - }, - }, - }, - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(translationResponseSchema), - }, - }, - description: 'Translation written', - }, - 401: { description: 'Unauthorized' }, - 403: { description: 'Forbidden' }, - }, - tags: ['Navigator'], - }), - ...authRouter(permissions.navigatorManage), - zValidator('json', updateTranslationSchema), - zValidator('param', translationParamsSchema), - async (c) => { - const { key, lang } = c.req.valid('param'); - const { text } = c.req.valid('json'); +export const updateTranslationRoute = base.navigator.translations.update + .use(requireAuthorization(permissions.navigatorManage)) + .handler(async ({ input }) => { + const { key, lang, text } = input; const [translation] = await db .insert(navigatorTranslation) @@ -217,33 +89,19 @@ export const updateTranslationRoute = navigatorFactory.createHandlers( }) .returning(); - return ok(c, { translation }); - } -); + if (!translation) { + throw new ORPCError('INTERNAL', { + message: 'Failed to write translation', + }); + } + + return { translation }; + }); -export const deleteTranslationRoute = navigatorFactory.createHandlers( - describeRoute({ - ...filcExt('Navigator', '@unit Translation', true), - description: 'Delete one codename in one language', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(translationResponseSchema), - }, - }, - description: 'Translation deleted', - }, - 401: { description: 'Unauthorized' }, - 403: { description: 'Forbidden' }, - 404: { description: 'Translation not found' }, - }, - tags: ['Navigator'], - }), - ...authRouter(permissions.navigatorManage), - zValidator('param', translationParamsSchema), - async (c) => { - const { key, lang } = c.req.valid('param'); +export const deleteTranslationRoute = base.navigator.translations.delete + .use(requireAuthorization(permissions.navigatorManage)) + .handler(async ({ input }) => { + const { key, lang } = input; const [translation] = await db .delete(navigatorTranslation) @@ -259,6 +117,5 @@ export const deleteTranslationRoute = navigatorFactory.createHandlers( throw notFound('Translation not found'); } - return ok(c, { translation }); - } -); + return { translation }; + }); diff --git a/apps/chronos/src/routes/news/_factory.ts b/apps/chronos/src/routes/news/_factory.ts deleted file mode 100644 index e0a3e2f0..00000000 --- a/apps/chronos/src/routes/news/_factory.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { createFactory } from 'hono/factory'; -import type { Context } from '#_types/globals'; - -export const newsFactory = createFactory(); diff --git a/apps/chronos/src/routes/news/_router.ts b/apps/chronos/src/routes/news/_router.ts index cac5ba3d..768e2b35 100644 --- a/apps/chronos/src/routes/news/_router.ts +++ b/apps/chronos/src/routes/news/_router.ts @@ -1,4 +1,3 @@ -import { newsFactory } from '#routes/news/_factory'; import { createAnnouncement, deleteAnnouncement, @@ -27,38 +26,32 @@ import { updateSystemMessage, } from '#routes/news/system-messages'; -const announcementsRouter = newsFactory - .createApp() - .get('/', ...listAnnouncements) - .get('/:id', ...getAnnouncement) - .post('/:id/image', ...uploadAnnouncementImage) - .delete('/:id/image', ...deleteAnnouncementImage) - .post('/', ...createAnnouncement) - .patch('/:id', ...updateAnnouncement) - .delete('/:id', ...deleteAnnouncement); - -const systemMessagesRouter = newsFactory - .createApp() - .get('/', ...listSystemMessages) - .get('/:id', ...getSystemMessage) - .post('/', ...createSystemMessage) - .patch('/:id', ...updateSystemMessage) - .delete('/:id', ...deleteSystemMessage); - -const blogsRouter = newsFactory - .createApp() - .get('/', ...listPublishedBlogs) - .get('/drafts', ...listDrafts) - .get('/id/:id', ...getBlogById) - .get('/:slug', ...getBlogBySlug) - .post('/', ...createBlog) - .patch('/:id', ...updateBlog) - .post('/:id/publish', ...publishBlog) - .post('/:id/unpublish', ...unpublishBlog) - .delete('/:id', ...deleteBlog); - -export const newsRouter = newsFactory - .createApp() - .route('/announcements', announcementsRouter) - .route('/system-messages', systemMessagesRouter) - .route('/blogs', blogsRouter); +export const newsRouter = { + announcements: { + create: createAnnouncement, + delete: deleteAnnouncement, + deleteImage: deleteAnnouncementImage, + get: getAnnouncement, + list: listAnnouncements, + update: updateAnnouncement, + uploadImage: uploadAnnouncementImage, + }, + blogs: { + create: createBlog, + delete: deleteBlog, + drafts: listDrafts, + get: getBlogBySlug, + getById: getBlogById, + list: listPublishedBlogs, + publish: publishBlog, + unpublish: unpublishBlog, + update: updateBlog, + }, + systemMessages: { + create: createSystemMessage, + delete: deleteSystemMessage, + get: getSystemMessage, + list: listSystemMessages, + update: updateSystemMessage, + }, +}; diff --git a/apps/chronos/src/routes/news/announcements.ts b/apps/chronos/src/routes/news/announcements.ts index 219df2f5..3f854962 100644 --- a/apps/chronos/src/routes/news/announcements.ts +++ b/apps/chronos/src/routes/news/announcements.ts @@ -1,18 +1,8 @@ -import { - type AnnouncementUpdateInput, - announcementCreateSchema, - announcementImageUploadSchema, - announcementQuerySchema, - announcementUpdateSchema, -} from '@filcdev/api/domains/news/announcements'; +import type { AnnouncementUpdateInput } from '@filcdev/api/domains/news/announcements'; import { permissions } from '@filcdev/api/permissions'; -import { zValidator } from '@hono/zod-validator'; import { getLogger } from '@logtape/logtape'; +import { ORPCError } from '@orpc/server'; import { and, count, eq, type SQL, sql } from 'drizzle-orm'; -import { HTTPException } from 'hono/http-exception'; -import { describeRoute, resolver } from 'hono-openapi'; -import { StatusCodes } from 'http-status-codes'; -import z from 'zod'; import { db } from '#database'; import { user } from '#database/schema/authentication'; import { @@ -20,26 +10,21 @@ import { announcementCohortMtm, announcementKioskMtm, } from '#database/schema/news'; -import { authRouter } from '#middleware/auth'; -import { newsFactory } from '#routes/news/_factory'; -import { ApiHttpError, badRequest, notFound, ok } from '#utils/http'; +import { requireAuthentication, requireAuthorization } from '#middleware/auth'; +import { base } from '#orpc'; +import { badRequest, notFound, serviceUnavailable } from '#utils/http'; import { activeAnnouncementConditions } from '#utils/news/announcements'; import { validateCohortIds } from '#utils/news/cohort'; import { validateKioskIds } from '#utils/news/kiosks'; import { - announcementBaseDetailResponseSchema, - announcementDetailResponseSchema, - announcementListResponseSchema, announcementSelect, authorSelect, resolveTitle, - successResponseSchema, } from '#utils/news/shared'; import { cancelPendingNotification, dispatchPendingNotification, } from '#utils/notifications/engine'; -import { filcExt } from '#utils/openapi'; import { announcementImageKey, deleteObject, @@ -167,44 +152,12 @@ async function setAnnouncementTargeting( } } -const { schema: createRequestSchema } = await resolver( - announcementCreateSchema -).toOpenAPISchema(); -const { schema: updateRequestSchema } = await resolver( - announcementUpdateSchema -).toOpenAPISchema(); -const { schema: uploadRequestSchema } = await resolver( - announcementImageUploadSchema -).toOpenAPISchema(); - -export const listAnnouncements = newsFactory.createHandlers( - describeRoute({ - ...filcExt( - 'Announcement', - '@listof Announcement @field(.author, Author)', - true - ), - description: - 'List active announcements within date range, cohort-filtered by default; includeAll=true returns everything', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(announcementListResponseSchema), - }, - }, - description: 'Paginated list of announcements', - }, - }, - tags: ['News / Announcements'], - }), - ...authRouter(), - zValidator('query', announcementQuerySchema), - async (c) => { +export const listAnnouncements = base.news.announcements.list + .use(requireAuthentication) + .handler(async ({ context, input }) => { const { limit, offset, includeExpired, includeAll, includeKioskOnly } = - c.req.valid('query'); - const currentUser = c.var.user; - const userCohortId = currentUser.cohortId; + input; + const userCohortId = context.user?.cohortId; // Any signed-in user may request all announcements (e.g. the public panel's // "Everyone" option) via includeAll=true. @@ -282,35 +235,13 @@ export const listAnnouncements = newsFactory.createHandlers( .map((m) => m.kioskId), })); - return ok(c, data, StatusCodes.OK, { total: totalResult[0]?.count ?? 0 }); - } -); - -export const getAnnouncement = newsFactory.createHandlers( - describeRoute({ - ...filcExt( - 'Announcement', - '@unit Announcement @field(.author, Author)', - true - ), - description: 'Get a single announcement by ID', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(announcementDetailResponseSchema), - }, - }, - description: 'Announcement details', - }, - 404: { description: 'Announcement not found' }, - }, - tags: ['News / Announcements'], - }), - ...authRouter(), - zValidator('param', z.object({ id: z.string().uuid() })), - async (c) => { - const { id } = c.req.valid('param'); + return { data, total: totalResult[0]?.count ?? 0 }; + }); + +export const getAnnouncement = base.news.announcements.get + .use(requireAuthentication) + .handler(async ({ input }) => { + const { id } = input; const [item] = await db .select({ ...announcementSelect, author: authorSelect }) @@ -319,47 +250,20 @@ export const getAnnouncement = newsFactory.createHandlers( .where(eq(announcement.id, id)); if (!item) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: 'Announcement not found', - }); + throw notFound('Announcement not found'); } const cohortIds = await announcementCohortIds(id); const kioskIds = await announcementKioskIds(id); - return ok(c, { ...item, cohortIds, kioskIds }); - } -); - -export const createAnnouncement = newsFactory.createHandlers( - describeRoute({ - ...filcExt('Announcement', '@unit Announcement', true), - description: 'Create a new announcement', - requestBody: { - content: { - 'application/json': { - schema: createRequestSchema, - }, - }, - }, - responses: { - 201: { - content: { - 'application/json': { - schema: resolver(announcementBaseDetailResponseSchema), - }, - }, - description: 'Announcement created', - }, - 400: { description: 'Invalid input or cohort IDs' }, - }, - tags: ['News / Announcements'], - }), - ...authRouter(permissions.announcementsCreate), - zValidator('json', announcementCreateSchema), - async (c) => { - const body = c.req.valid('json'); - const currentUser = c.var.user; + return { ...item, cohortIds, kioskIds }; + }); + +export const createAnnouncement = base.news.announcements.create + .use(requireAuthorization(permissions.announcementsCreate)) + .handler(async ({ context, input }) => { + const body = input; + const authorId = context.session.userId; assertFeaturedAnnouncementIsTitled(body.highlighted ?? false, body.title); @@ -368,7 +272,7 @@ export const createAnnouncement = newsFactory.createHandlers( const [created] = await db .insert(announcement) .values({ - authorId: currentUser.id, + authorId, content: body.content, highlighted: body.highlighted ?? false, kioskOnly: body.kioskOnly ?? false, @@ -378,7 +282,7 @@ export const createAnnouncement = newsFactory.createHandlers( }) .returning(); if (!created) { - throw new HTTPException(StatusCodes.INTERNAL_SERVER_ERROR, { + throw new ORPCError('INTERNAL', { message: 'Failed to create announcement', }); } @@ -396,49 +300,18 @@ export const createAnnouncement = newsFactory.createHandlers( }); } - return ok( - c, - { - ...created, - cohortIds: body.cohortIds ?? [], - kioskIds: body.kioskIds ?? [], - }, - StatusCodes.CREATED - ); - } -); - -export const updateAnnouncement = newsFactory.createHandlers( - describeRoute({ - ...filcExt('Announcement', '@unit Announcement', true), - description: 'Update an existing announcement', - requestBody: { - content: { - 'application/json': { - schema: updateRequestSchema, - }, - }, - }, - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(announcementBaseDetailResponseSchema), - }, - }, - description: 'Announcement updated', - }, - 400: { description: 'Invalid input or date range' }, - 404: { description: 'Announcement not found' }, - }, - tags: ['News / Announcements'], - }), - ...authRouter(permissions.announcementsCreate), - zValidator('param', z.object({ id: z.string().uuid() })), - zValidator('json', announcementUpdateSchema), - async (c) => { - const { id } = c.req.valid('param'); - const body = c.req.valid('json'); + return { + ...created, + cohortIds: body.cohortIds ?? [], + kioskIds: body.kioskIds ?? [], + }; + }); + +export const updateAnnouncement = base.news.announcements.update + .use(requireAuthorization(permissions.announcementsCreate)) + .handler(async ({ input }) => { + const { id } = input; + const body: AnnouncementUpdateInput = input; const [existing] = await db .select() @@ -446,18 +319,14 @@ export const updateAnnouncement = newsFactory.createHandlers( .where(eq(announcement.id, id)); if (!existing) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: 'Announcement not found', - }); + throw notFound('Announcement not found'); } // Validate date range with existing values const validFrom = body.validFrom ?? existing.validFrom; const validUntil = body.validUntil ?? existing.validUntil; if (validUntil < validFrom) { - throw new HTTPException(StatusCodes.BAD_REQUEST, { - message: 'validUntil must be on or after validFrom', - }); + throw badRequest('validUntil must be on or after validFrom'); } // Same invariant as on create, judged against the row this update produces. @@ -481,9 +350,7 @@ export const updateAnnouncement = newsFactory.createHandlers( .where(eq(announcement.id, id)) .returning(); if (!row) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: 'Announcement not found', - }); + throw notFound('Announcement not found'); } updated = row; } @@ -503,31 +370,13 @@ export const updateAnnouncement = newsFactory.createHandlers( }); } - return ok(c, { ...updated, cohortIds, kioskIds }); - } -); - -export const deleteAnnouncement = newsFactory.createHandlers( - describeRoute({ - ...filcExt('Announcement', '@nodata', true), - description: 'Delete an announcement', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(successResponseSchema), - }, - }, - description: 'Announcement deleted', - }, - 404: { description: 'Announcement not found' }, - }, - tags: ['News / Announcements'], - }), - ...authRouter(permissions.announcementsCreate), - zValidator('param', z.object({ id: z.string().uuid() })), - async (c) => { - const { id } = c.req.valid('param'); + return { ...updated, cohortIds, kioskIds }; + }); + +export const deleteAnnouncement = base.news.announcements.delete + .use(requireAuthorization(permissions.announcementsCreate)) + .handler(async ({ input }) => { + const { id } = input; const [deleted] = await db .delete(announcement) @@ -535,9 +384,7 @@ export const deleteAnnouncement = newsFactory.createHandlers( .returning(); if (!deleted) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: 'Announcement not found', - }); + throw notFound('Announcement not found'); } // The row is gone either way; a failed object delete must not turn a @@ -555,47 +402,13 @@ export const deleteAnnouncement = newsFactory.createHandlers( cancelPendingNotification(id, 'announcement'); - return ok(c, undefined); - } -); - -export const uploadAnnouncementImage = newsFactory.createHandlers( - describeRoute({ - ...filcExt('Announcement', '@unit Announcement', true), - description: 'Upload the image this announcement shows on the kiosk', - requestBody: { - content: { - 'multipart/form-data': { - schema: uploadRequestSchema, - }, - }, - }, - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(announcementBaseDetailResponseSchema), - }, - }, - description: 'Image uploaded', - }, - 400: { - description: - 'Unsupported image type, image too large, or the announcement has no title', - }, - 404: { description: 'Announcement not found' }, - 503: { description: 'Object storage is not configured' }, - }, - tags: ['News / Announcements'], - }), - // Authenticate before the form validator so an anonymous request cannot - // force the whole upload into memory (`z.file()` is unbounded). - ...authRouter(permissions.announcementsCreate), - zValidator('param', z.object({ id: z.string().uuid() })), - zValidator('form', announcementImageUploadSchema), - async (c) => { - const { id } = c.req.valid('param'); - const { file } = c.req.valid('form'); + return { id }; + }); + +export const uploadAnnouncementImage = base.news.announcements.uploadImage + .use(requireAuthorization(permissions.announcementsCreate)) + .handler(async ({ input }) => { + const { file, id } = input; const [existing] = await db .select() @@ -607,9 +420,7 @@ export const uploadAnnouncementImage = newsFactory.createHandlers( } if (!isObjectStorageConfigured()) { - throw new ApiHttpError(StatusCodes.SERVICE_UNAVAILABLE, { - message: 'Object storage is not configured', - }); + throw serviceUnavailable('Object storage is not configured'); } const extension = ANNOUNCEMENT_IMAGE_EXTENSIONS[file.type]; @@ -658,35 +469,17 @@ export const uploadAnnouncementImage = newsFactory.createHandlers( } } - return ok(c, { + return { ...updated, cohortIds: await announcementCohortIds(id), kioskIds: await announcementKioskIds(id), - }); - } -); - -export const deleteAnnouncementImage = newsFactory.createHandlers( - describeRoute({ - ...filcExt('Announcement', '@unit Announcement', true), - description: 'Delete the image attached to an announcement', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(announcementBaseDetailResponseSchema), - }, - }, - description: 'Image deleted', - }, - 404: { description: 'Announcement not found, or it has no image' }, - }, - tags: ['News / Announcements'], - }), - ...authRouter(permissions.announcementsCreate), - zValidator('param', z.object({ id: z.string().uuid() })), - async (c) => { - const { id } = c.req.valid('param'); + }; + }); + +export const deleteAnnouncementImage = base.news.announcements.deleteImage + .use(requireAuthorization(permissions.announcementsCreate)) + .handler(async ({ input }) => { + const { id } = input; const [existing] = await db .select() @@ -725,10 +518,9 @@ export const deleteAnnouncementImage = newsFactory.createHandlers( logger.warn('Failed to delete an announcement image', { error, key }); } - return ok(c, { + return { ...updated, cohortIds: await announcementCohortIds(id), kioskIds: await announcementKioskIds(id), - }); - } -); + }; + }); diff --git a/apps/chronos/src/routes/news/blogs.ts b/apps/chronos/src/routes/news/blogs.ts index 5c48fe1e..fd8dd365 100644 --- a/apps/chronos/src/routes/news/blogs.ts +++ b/apps/chronos/src/routes/news/blogs.ts @@ -1,28 +1,18 @@ -import { paginationSchema } from '@filcdev/api/domains/news/announcements'; -import { - blogCreateSchema, - blogUpdateSchema, -} from '@filcdev/api/domains/news/blogs'; -import { zValidator } from '@hono/zod-validator'; +import type { BlogUpdateInput } from '@filcdev/api/domains/news/blogs'; +import { permissions } from '@filcdev/api/permissions'; +import { ORPCError } from '@orpc/server'; import { and, count, eq, sql } from 'drizzle-orm'; -import { HTTPException } from 'hono/http-exception'; -import { describeRoute, resolver } from 'hono-openapi'; -import { StatusCodes } from 'http-status-codes'; -import z from 'zod'; import { db } from '#database'; import { user } from '#database/schema/authentication'; import { blogPost } from '#database/schema/news'; -import { authRouter } from '#middleware/auth'; -import { newsFactory } from '#routes/news/_factory'; -import { created, notFound, ok } from '#utils/http'; +import { requireAuthorization } from '#middleware/auth'; +import { base } from '#orpc'; +import { badRequest, notFound } from '#utils/http'; import { ensureUniqueSlug, generateSlug } from '#utils/news/schemas'; -import { successResponseSchema } from '#utils/news/shared'; import { cancelPendingNotification, dispatchPendingNotification, } from '#utils/notifications/engine'; -import { filcExt } from '#utils/openapi'; -import { createSelectSchema } from '#utils/zod'; const authorSelect = { id: user.id, @@ -30,38 +20,6 @@ const authorSelect = { name: user.name, }; -const blogSelectSchema = createSelectSchema(blogPost); -const authorSchema = z.object({ - id: z.string(), - image: z.string().nullable(), - name: z.string(), -}); - -const blogItemSchema = blogSelectSchema.extend({ - author: authorSchema.nullable().optional(), -}); - -const blogListResponseSchema = z.object({ - data: z.array(blogItemSchema), - success: z.literal(true), - total: z.number(), -}); - -const blogDetailResponseSchema = z.object({ - data: blogItemSchema, - success: z.literal(true), -}); - -const blogBaseDetailResponseSchema = z.object({ - data: blogSelectSchema, - success: z.literal(true), -}); - -const { schema: createBlogRequestSchema } = - await resolver(blogCreateSchema).toOpenAPISchema(); -const { schema: updateBlogRequestSchema } = - await resolver(blogUpdateSchema).toOpenAPISchema(); - const checkSlugExists = async (slug: string, excludeId?: string) => { const conditions = [eq(blogPost.slug, slug)]; if (excludeId) { @@ -74,25 +32,9 @@ const checkSlugExists = async (slug: string, excludeId?: string) => { return !!existing; }; -export const listPublishedBlogs = newsFactory.createHandlers( - describeRoute({ - ...filcExt('BlogPost', '@listof BlogPost @field(.author, Author)'), - description: 'List published blog posts (public, no auth required)', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(blogListResponseSchema), - }, - }, - description: 'Paginated list of published blog posts', - }, - }, - tags: ['News / Blogs'], - }), - zValidator('query', paginationSchema), - async (c) => { - const { limit, offset } = c.req.valid('query'); +export const listPublishedBlogs = base.news.blogs.list.handler( + async ({ input }) => { + const { limit, offset } = input; const where = eq(blogPost.status, 'published'); @@ -119,78 +61,41 @@ export const listPublishedBlogs = newsFactory.createHandlers( db.select({ count: count() }).from(blogPost).where(where), ]); - return ok(c, items, StatusCodes.OK, { - total: totalResult[0]?.count ?? 0, - }); + return { data: items, total: totalResult[0]?.count ?? 0 }; } ); -export const getBlogBySlug = newsFactory.createHandlers( - describeRoute({ - ...filcExt('BlogPost', '@unit BlogPost @field(.author, Author)'), - description: 'Get a published blog post by slug (public, no auth required)', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(blogDetailResponseSchema), - }, - }, - description: 'Blog post details', - }, - 404: { description: 'Blog post not found' }, - }, - tags: ['News / Blogs'], - }), - zValidator('param', z.object({ slug: z.string() })), - async (c) => { - const { slug } = c.req.valid('param'); - - const [item] = await db - .select({ - author: authorSelect, - authorId: blogPost.authorId, - content: blogPost.content, - createdAt: blogPost.createdAt, - id: blogPost.id, - publishedAt: blogPost.publishedAt, - slug: blogPost.slug, - status: blogPost.status, - title: blogPost.title, - updatedAt: blogPost.updatedAt, - }) - .from(blogPost) - .leftJoin(user, eq(blogPost.authorId, user.id)) - .where(and(eq(blogPost.slug, slug), eq(blogPost.status, 'published'))); - - if (!item) { - throw notFound('Blog post not found'); - } +export const getBlogBySlug = base.news.blogs.get.handler(async ({ input }) => { + const { slug } = input; + + const [item] = await db + .select({ + author: authorSelect, + authorId: blogPost.authorId, + content: blogPost.content, + createdAt: blogPost.createdAt, + id: blogPost.id, + publishedAt: blogPost.publishedAt, + slug: blogPost.slug, + status: blogPost.status, + title: blogPost.title, + updatedAt: blogPost.updatedAt, + }) + .from(blogPost) + .leftJoin(user, eq(blogPost.authorId, user.id)) + .where(and(eq(blogPost.slug, slug), eq(blogPost.status, 'published'))); - return ok(c, item); + if (!item) { + throw notFound('Blog post not found'); } -); -export const listDrafts = newsFactory.createHandlers( - describeRoute({ - ...filcExt('BlogPost', '@listof BlogPost @field(.author, Author)', true), - description: 'List all blog posts including drafts (requires permission)', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(blogListResponseSchema), - }, - }, - description: 'Paginated list of all blog posts', - }, - }, - tags: ['News / Blogs'], - }), - ...authRouter('news:blogs'), - zValidator('query', paginationSchema), - async (c) => { - const { limit, offset } = c.req.valid('query'); + return item; +}); + +export const listDrafts = base.news.blogs.drafts + .use(requireAuthorization(permissions.newsBlogsManage)) + .handler(async ({ input }) => { + const { limit, offset } = input; const [items, totalResult] = await Promise.all([ db @@ -214,34 +119,13 @@ export const listDrafts = newsFactory.createHandlers( db.select({ count: count() }).from(blogPost), ]); - return ok(c, items, StatusCodes.OK, { - total: totalResult[0]?.count ?? 0, - }); - } -); + return { data: items, total: totalResult[0]?.count ?? 0 }; + }); -export const getBlogById = newsFactory.createHandlers( - describeRoute({ - ...filcExt('BlogPost', '@unit BlogPost @field(.author, Author)', true), - description: - 'Get any blog post by ID including drafts (requires permission)', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(blogDetailResponseSchema), - }, - }, - description: 'Blog post details', - }, - 404: { description: 'Blog post not found' }, - }, - tags: ['News / Blogs'], - }), - ...authRouter('news:blogs'), - zValidator('param', z.object({ id: z.string().uuid() })), - async (c) => { - const { id } = c.req.valid('param'); +export const getBlogById = base.news.blogs.getById + .use(requireAuthorization(permissions.newsBlogsManage)) + .handler(async ({ input }) => { + const { id } = input; const [item] = await db .select({ @@ -264,39 +148,14 @@ export const getBlogById = newsFactory.createHandlers( throw notFound('Blog post not found'); } - return ok(c, item); - } -); + return item; + }); -export const createBlog = newsFactory.createHandlers( - describeRoute({ - ...filcExt('BlogPost', '@unit BlogPost', true), - description: 'Create a new blog post (defaults to draft)', - requestBody: { - content: { - 'application/json': { - schema: createBlogRequestSchema, - }, - }, - }, - responses: { - 201: { - content: { - 'application/json': { - schema: resolver(blogBaseDetailResponseSchema), - }, - }, - description: 'Blog post created', - }, - 400: { description: 'Invalid input' }, - }, - tags: ['News / Blogs'], - }), - ...authRouter('news:blogs'), - zValidator('json', blogCreateSchema), - async (c) => { - const body = c.req.valid('json'); - const currentUser = c.var.user; +export const createBlog = base.news.blogs.create + .use(requireAuthorization(permissions.newsBlogsManage)) + .handler(async ({ context, input }) => { + const body = input; + const authorId = context.session.userId; const baseSlug = body.slug ?? generateSlug(body.title); const slug = await ensureUniqueSlug(baseSlug, (s) => checkSlugExists(s)); @@ -306,7 +165,7 @@ export const createBlog = newsFactory.createHandlers( const [row] = await db .insert(blogPost) .values({ - authorId: currentUser.id, + authorId, content: body.content, publishedAt, slug, @@ -315,40 +174,20 @@ export const createBlog = newsFactory.createHandlers( }) .returning(); - return created(c, row); - } -); + if (!row) { + throw new ORPCError('INTERNAL', { + message: 'Failed to create blog post', + }); + } -export const updateBlog = newsFactory.createHandlers( - describeRoute({ - ...filcExt('BlogPost', '@unit BlogPost', true), - description: 'Update a blog post', - requestBody: { - content: { - 'application/json': { - schema: updateBlogRequestSchema, - }, - }, - }, - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(blogBaseDetailResponseSchema), - }, - }, - description: 'Blog post updated', - }, - 404: { description: 'Blog post not found' }, - }, - tags: ['News / Blogs'], - }), - ...authRouter('news:blogs'), - zValidator('param', z.object({ id: z.string().uuid() })), - zValidator('json', blogUpdateSchema), - async (c) => { - const { id } = c.req.valid('param'); - const body = c.req.valid('json'); + return row; + }); + +export const updateBlog = base.news.blogs.update + .use(requireAuthorization(permissions.newsBlogsManage)) + .handler(async ({ input }) => { + const { id } = input; + const body: BlogUpdateInput = input; const [existing] = await db .select() @@ -380,32 +219,17 @@ export const updateBlog = newsFactory.createHandlers( .where(eq(blogPost.id, id)) .returning(); - return ok(c, updated); - } -); + if (!updated) { + throw notFound('Blog post not found'); + } + + return updated; + }); -export const publishBlog = newsFactory.createHandlers( - describeRoute({ - ...filcExt('BlogPost', '@unit BlogPost', true), - description: 'Publish a blog post (draft → published)', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(blogBaseDetailResponseSchema), - }, - }, - description: 'Blog post published', - }, - 400: { description: 'Blog post is already published' }, - 404: { description: 'Blog post not found' }, - }, - tags: ['News / Blogs'], - }), - ...authRouter('news:blogs'), - zValidator('param', z.object({ id: z.string().uuid() })), - async (c) => { - const { id } = c.req.valid('param'); +export const publishBlog = base.news.blogs.publish + .use(requireAuthorization(permissions.newsBlogsManage)) + .handler(async ({ input }) => { + const { id } = input; const [existing] = await db .select() @@ -417,9 +241,7 @@ export const publishBlog = newsFactory.createHandlers( } if (existing.status === 'published') { - throw new HTTPException(StatusCodes.BAD_REQUEST, { - message: 'Blog post is already published', - }); + throw badRequest('Blog post is already published'); } const [updated] = await db @@ -428,37 +250,22 @@ export const publishBlog = newsFactory.createHandlers( .where(eq(blogPost.id, id)) .returning(); + if (!updated) { + throw notFound('Blog post not found'); + } + dispatchPendingNotification(id, 'blog_post', { slug: existing.slug, title: existing.title, }); - return ok(c, updated); - } -); + return updated; + }); -export const unpublishBlog = newsFactory.createHandlers( - describeRoute({ - ...filcExt('BlogPost', '@unit BlogPost', true), - description: 'Unpublish a blog post (published → draft)', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(blogBaseDetailResponseSchema), - }, - }, - description: 'Blog post unpublished', - }, - 400: { description: 'Blog post is already a draft' }, - 404: { description: 'Blog post not found' }, - }, - tags: ['News / Blogs'], - }), - ...authRouter('news:blogs'), - zValidator('param', z.object({ id: z.string().uuid() })), - async (c) => { - const { id } = c.req.valid('param'); +export const unpublishBlog = base.news.blogs.unpublish + .use(requireAuthorization(permissions.newsBlogsManage)) + .handler(async ({ input }) => { + const { id } = input; const [existing] = await db .select() @@ -470,9 +277,7 @@ export const unpublishBlog = newsFactory.createHandlers( } if (existing.status === 'draft') { - throw new HTTPException(StatusCodes.BAD_REQUEST, { - message: 'Blog post is already a draft', - }); + throw badRequest('Blog post is already a draft'); } const [updated] = await db @@ -481,33 +286,19 @@ export const unpublishBlog = newsFactory.createHandlers( .where(eq(blogPost.id, id)) .returning(); + if (!updated) { + throw notFound('Blog post not found'); + } + cancelPendingNotification(id, 'blog_post'); - return ok(c, updated); - } -); + return updated; + }); -export const deleteBlog = newsFactory.createHandlers( - describeRoute({ - ...filcExt('BlogPost', '@nodata', true), - description: 'Delete a blog post', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(successResponseSchema), - }, - }, - description: 'Blog post deleted', - }, - 404: { description: 'Blog post not found' }, - }, - tags: ['News / Blogs'], - }), - ...authRouter('news:blogs'), - zValidator('param', z.object({ id: z.string().uuid() })), - async (c) => { - const { id } = c.req.valid('param'); +export const deleteBlog = base.news.blogs.delete + .use(requireAuthorization(permissions.newsBlogsManage)) + .handler(async ({ input }) => { + const { id } = input; const [deleted] = await db .delete(blogPost) @@ -518,6 +309,5 @@ export const deleteBlog = newsFactory.createHandlers( throw notFound('Blog post not found'); } - return ok(c, undefined); - } -); + return { id }; + }); diff --git a/apps/chronos/src/routes/news/system-messages.ts b/apps/chronos/src/routes/news/system-messages.ts index 21e81f51..cca628ef 100644 --- a/apps/chronos/src/routes/news/system-messages.ts +++ b/apps/chronos/src/routes/news/system-messages.ts @@ -1,65 +1,24 @@ -import { announcementQuerySchema } from '@filcdev/api/domains/news/announcements'; -import { - dateRangeBodySchema, - dateRangeUpdateBodySchema, -} from '@filcdev/api/domains/news/system-messages'; -import { zValidator } from '@hono/zod-validator'; +import type { DateRangeUpdateBodyInput } from '@filcdev/api/domains/news/system-messages'; +import { permissions } from '@filcdev/api/permissions'; +import { ORPCError } from '@orpc/server'; import { and, count, eq, gte, lte, type SQL, sql } from 'drizzle-orm'; -import { HTTPException } from 'hono/http-exception'; -import { describeRoute, resolver } from 'hono-openapi'; -import { StatusCodes } from 'http-status-codes'; -import z from 'zod'; import { db } from '#database'; import { user } from '#database/schema/authentication'; import { systemMessage, systemMessageCohortMtm } from '#database/schema/news'; -import { authRouter } from '#middleware/auth'; -import { newsFactory } from '#routes/news/_factory'; -import { ok } from '#utils/http'; +import { requireAuthorization } from '#middleware/auth'; +import { base } from '#orpc'; +import { badRequest, notFound } from '#utils/http'; import { validateCohortIds } from '#utils/news/cohort'; -import { - authorSelect, - successResponseSchema, - systemMessageBaseDetailResponseSchema, - systemMessageDetailResponseSchema, - systemMessageListResponseSchema, -} from '#utils/news/shared'; +import { authorSelect } from '#utils/news/shared'; import { cancelPendingNotification, dispatchPendingNotification, } from '#utils/notifications/engine'; -import { filcExt } from '#utils/openapi'; - -const { schema: createRequestSchema } = - await resolver(dateRangeBodySchema).toOpenAPISchema(); -const { schema: updateRequestSchema } = await resolver( - dateRangeUpdateBodySchema -).toOpenAPISchema(); - -export const listSystemMessages = newsFactory.createHandlers( - describeRoute({ - ...filcExt( - 'SystemMessage', - '@listof SystemMessage @field(.author, Author)', - true - ), - description: - 'List active system messages within date range, filtered by user cohort', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(systemMessageListResponseSchema), - }, - }, - description: 'Paginated list of system messages', - }, - }, - tags: ['News / System Messages'], - }), - zValidator('query', announcementQuerySchema), - async (c) => { - const { limit, offset, includeExpired } = c.req.valid('query'); - const userCohortId = c.var.user?.cohortId; + +export const listSystemMessages = base.news.systemMessages.list.handler( + async ({ context, input }) => { + const { limit, offset, includeExpired } = input; + const userCohortId = context.user?.cohortId; const now = new Date(); const conditions: SQL[] = []; @@ -118,34 +77,13 @@ export const listSystemMessages = newsFactory.createHandlers( .map((m) => m.cohortId), })); - return ok(c, data, StatusCodes.OK, { total: totalResult[0]?.count ?? 0 }); + return { data, total: totalResult[0]?.count ?? 0 }; } ); -export const getSystemMessage = newsFactory.createHandlers( - describeRoute({ - ...filcExt( - 'SystemMessage', - '@unit SystemMessage @field(.author, Author)', - true - ), - description: 'Get a single system message by ID', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(systemMessageDetailResponseSchema), - }, - }, - description: 'System message details', - }, - 404: { description: 'System message not found' }, - }, - tags: ['News / System Messages'], - }), - zValidator('param', z.object({ id: z.string().uuid() })), - async (c) => { - const { id } = c.req.valid('param'); +export const getSystemMessage = base.news.systemMessages.get.handler( + async ({ input }) => { + const { id } = input; const [item] = await db .select({ @@ -164,9 +102,7 @@ export const getSystemMessage = newsFactory.createHandlers( .where(eq(systemMessage.id, id)); if (!item) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: 'System message not found', - }); + throw notFound('System message not found'); } const cohortIds = ( @@ -176,39 +112,15 @@ export const getSystemMessage = newsFactory.createHandlers( .where(eq(systemMessageCohortMtm.systemMessageId, id)) ).map((m) => m.cohortId); - return ok(c, { ...item, cohortIds }); + return { ...item, cohortIds }; } ); -export const createSystemMessage = newsFactory.createHandlers( - describeRoute({ - ...filcExt('SystemMessage', '@unit SystemMessage', true), - description: 'Create a new system message', - requestBody: { - content: { - 'application/json': { - schema: createRequestSchema, - }, - }, - }, - responses: { - 201: { - content: { - 'application/json': { - schema: resolver(systemMessageBaseDetailResponseSchema), - }, - }, - description: 'System message created', - }, - 400: { description: 'Invalid input or cohort IDs' }, - }, - tags: ['News / System Messages'], - }), - ...authRouter('system-messages:manage'), - zValidator('json', dateRangeBodySchema), - async (c) => { - const body = c.req.valid('json'); - const currentUser = c.var.user; +export const createSystemMessage = base.news.systemMessages.create + .use(requireAuthorization(permissions.systemMessagesManage)) + .handler(async ({ context, input }) => { + const body = input; + const authorId = context.session.userId; if (body.cohortIds && body.cohortIds.length > 0) { await validateCohortIds(body.cohortIds); @@ -217,7 +129,7 @@ export const createSystemMessage = newsFactory.createHandlers( const [created] = await db .insert(systemMessage) .values({ - authorId: currentUser.id, + authorId, content: body.content, title: body.title, validFrom: body.validFrom, @@ -225,7 +137,7 @@ export const createSystemMessage = newsFactory.createHandlers( }) .returning(); if (!created) { - throw new HTTPException(StatusCodes.INTERNAL_SERVER_ERROR, { + throw new ORPCError('INTERNAL', { message: 'Failed to create system message', }); } @@ -244,45 +156,14 @@ export const createSystemMessage = newsFactory.createHandlers( title: body.title, }); - return ok( - c, - { ...created, cohortIds: body.cohortIds ?? [] }, - StatusCodes.CREATED - ); - } -); + return { ...created, cohortIds: body.cohortIds ?? [] }; + }); -export const updateSystemMessage = newsFactory.createHandlers( - describeRoute({ - ...filcExt('SystemMessage', '@unit SystemMessage', true), - description: 'Update an existing system message', - requestBody: { - content: { - 'application/json': { - schema: updateRequestSchema, - }, - }, - }, - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(systemMessageBaseDetailResponseSchema), - }, - }, - description: 'System message updated', - }, - 400: { description: 'Invalid input or date range' }, - 404: { description: 'System message not found' }, - }, - tags: ['News / System Messages'], - }), - ...authRouter('system-messages:manage'), - zValidator('param', z.object({ id: z.string().uuid() })), - zValidator('json', dateRangeUpdateBodySchema), - async (c) => { - const { id } = c.req.valid('param'); - const body = c.req.valid('json'); +export const updateSystemMessage = base.news.systemMessages.update + .use(requireAuthorization(permissions.systemMessagesManage)) + .handler(async ({ input }) => { + const { id } = input; + const body: DateRangeUpdateBodyInput = input; const [existing] = await db .select() @@ -290,17 +171,13 @@ export const updateSystemMessage = newsFactory.createHandlers( .where(eq(systemMessage.id, id)); if (!existing) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: 'System message not found', - }); + throw notFound('System message not found'); } const validFrom = body.validFrom ?? existing.validFrom; const validUntil = body.validUntil ?? existing.validUntil; if (validUntil <= validFrom) { - throw new HTTPException(StatusCodes.BAD_REQUEST, { - message: 'validUntil must be after validFrom', - }); + throw badRequest('validUntil must be after validFrom'); } if (body.cohortIds && body.cohortIds.length > 0) { @@ -329,9 +206,7 @@ export const updateSystemMessage = newsFactory.createHandlers( .where(eq(systemMessage.id, id)) .returning(); if (!updated) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: 'System message not found', - }); + throw notFound('System message not found'); } if (body.cohortIds !== undefined) { @@ -363,31 +238,13 @@ export const updateSystemMessage = newsFactory.createHandlers( title: updated.title, }); - return ok(c, { ...updated, cohortIds }); - } -); + return { ...updated, cohortIds }; + }); -export const deleteSystemMessage = newsFactory.createHandlers( - describeRoute({ - ...filcExt('SystemMessage', '@nodata', true), - description: 'Delete a system message', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(successResponseSchema), - }, - }, - description: 'System message deleted', - }, - 404: { description: 'System message not found' }, - }, - tags: ['News / System Messages'], - }), - ...authRouter('system-messages:manage'), - zValidator('param', z.object({ id: z.string().uuid() })), - async (c) => { - const { id } = c.req.valid('param'); +export const deleteSystemMessage = base.news.systemMessages.delete + .use(requireAuthorization(permissions.systemMessagesManage)) + .handler(async ({ input }) => { + const { id } = input; const [deleted] = await db .delete(systemMessage) @@ -395,13 +252,10 @@ export const deleteSystemMessage = newsFactory.createHandlers( .returning(); if (!deleted) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: 'System message not found', - }); + throw notFound('System message not found'); } cancelPendingNotification(id, 'system_message'); - return ok(c, undefined); - } -); + return { id }; + }); diff --git a/apps/chronos/src/routes/notifications/_factory.ts b/apps/chronos/src/routes/notifications/_factory.ts deleted file mode 100644 index bee4f81f..00000000 --- a/apps/chronos/src/routes/notifications/_factory.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { createFactory } from 'hono/factory'; -import type { Context } from '#_types/globals'; - -export const notificationsFactory = createFactory(); diff --git a/apps/chronos/src/routes/notifications/_router.ts b/apps/chronos/src/routes/notifications/_router.ts index 0356e6da..aecfea9d 100644 --- a/apps/chronos/src/routes/notifications/_router.ts +++ b/apps/chronos/src/routes/notifications/_router.ts @@ -1,13 +1,10 @@ -import { notificationsFactory } from '#routes/notifications/_factory'; import { getNotificationSettings, getUnreadCount, - getUnsubscribePage, listNotifications, markAllAsRead, markAsRead, previewTestNotification, - processUnsubscribe, registerFcmToken, sendTestNotification, testNotification, @@ -15,18 +12,22 @@ import { updateNotificationSettings, } from '#routes/notifications/notifications'; -export const notificationsRouter = notificationsFactory - .createApp() - .get('/', ...listNotifications) - .get('/unread-count', ...getUnreadCount) - .get('/test', ...testNotification) - .post('/send-test', ...sendTestNotification) - .post('/preview-test', ...previewTestNotification) - .get('/settings', ...getNotificationSettings) - .patch('/settings', ...updateNotificationSettings) - .post('/fcm-token', ...registerFcmToken) - .delete('/fcm-token', ...unregisterFcmToken) - .patch('/:id/read', ...markAsRead) - .patch('/read-all', ...markAllAsRead) - .get('/unsubscribe', ...getUnsubscribePage) - .post('/unsubscribe', ...processUnsubscribe); +/** + * Mirrors `notificationsContract`. `GET|POST /api/notifications/unsubscribe` + * is not here: it is the plain HTML handler in `unsubscribe-html.ts`. + */ +export const notificationsRouter = { + fcmTokens: { + register: registerFcmToken, + unregister: unregisterFcmToken, + }, + list: listNotifications, + markAllAsRead, + markAsRead, + previewTest: previewTestNotification, + sendTest: sendTestNotification, + settings: getNotificationSettings, + test: testNotification, + unreadCount: getUnreadCount, + updateSettings: updateNotificationSettings, +}; diff --git a/apps/chronos/src/routes/notifications/notifications.ts b/apps/chronos/src/routes/notifications/notifications.ts index cde57c72..39e037f4 100644 --- a/apps/chronos/src/routes/notifications/notifications.ts +++ b/apps/chronos/src/routes/notifications/notifications.ts @@ -1,17 +1,5 @@ -import { - fcmTokenSchema, - notificationIdParamsSchema, - paginationSchema, - previewTestNotificationSchema, - sendTestNotificationSchema, - tokenDeleteSchema, - unsubscribeSchema, - updateSettingsSchema, -} from '@filcdev/api/domains/notifications'; -import { zValidator } from '@hono/zod-validator'; +import { ORPCError } from '@orpc/server'; import { and, count, desc, eq, sql } from 'drizzle-orm'; -import { HTTPException } from 'hono/http-exception'; -import { StatusCodes } from 'http-status-codes'; import { db } from '#database'; import { user as userTable } from '#database/schema/authentication'; import { @@ -19,50 +7,19 @@ import { notification, userPreferences, } from '#database/schema/notifications'; -import { authRouter } from '#middleware/auth'; -import { notificationsFactory } from '#routes/notifications/_factory'; +import { requireAuthentication } from '#middleware/auth'; +import { base } from '#orpc'; import { env } from '#utils/environment'; -import { created as createdResponse, notFound, ok } from '#utils/http'; +import { forbidden, notFound } from '#utils/http'; import { sendPush } from '#utils/notifications/providers/fcm'; -import { - generateUnsubscribeToken, - renderEmail, - sendEmail, -} from '#utils/notifications/providers/smtp'; +import { renderEmail, sendEmail } from '#utils/notifications/providers/smtp'; import { enqueue } from '#utils/notifications/queue'; -/** HTML-escape a string for interpolation into markup (e.g. reflected tokens). */ -const escapeHtml = (value: string): string => - value.replace(/[&<>"']/g, (ch) => { - switch (ch) { - case '&': - return '&'; - case '<': - return '<'; - case '>': - return '>'; - case '"': - return '"'; - default: - return '''; - } - }); - -const getUser = (c: { var: { user: { id: string } | null } }) => { - const user = c.var.user; - if (!user) { - throw new HTTPException(StatusCodes.UNAUTHORIZED); - } - return user; -}; - -export const listNotifications = notificationsFactory.createHandlers( - ...authRouter(), - zValidator('query', paginationSchema), - async (c) => { - const { id: userId } = getUser(c); - const { limit, offset, type, unread, dateFrom, dateTo } = - c.req.valid('query'); +export const listNotifications = base.notifications.list + .use(requireAuthentication) + .handler(async ({ context, input }) => { + const userId = context.session.userId; + const { limit, offset, type, unread, dateFrom, dateTo } = input; const conditions = [eq(notification.userId, userId)]; @@ -95,16 +52,13 @@ export const listNotifications = notificationsFactory.createHandlers( .where(and(...conditions)), ]); - return ok(c, items, StatusCodes.OK, { - total: totalResult[0]?.count ?? 0, - }); - } -); + return { items, total: totalResult[0]?.count ?? 0 }; + }); -export const getUnreadCount = notificationsFactory.createHandlers( - ...authRouter(), - async (c) => { - const { id: userId } = getUser(c); +export const getUnreadCount = base.notifications.unreadCount + .use(requireAuthentication) + .handler(async ({ context }) => { + const userId = context.session.userId; const [result] = await db .select({ count: count() }) @@ -113,16 +67,14 @@ export const getUnreadCount = notificationsFactory.createHandlers( sql`${notification.userId} = ${userId} AND ${notification.read} = false` ); - return ok(c, { count: result?.count ?? 0 }); - } -); + return { count: result?.count ?? 0 }; + }); -export const markAsRead = notificationsFactory.createHandlers( - ...authRouter(), - zValidator('param', notificationIdParamsSchema), - async (c) => { - const { id: userId } = getUser(c); - const { id } = c.req.valid('param'); +export const markAsRead = base.notifications.markAsRead + .use(requireAuthentication) + .handler(async ({ context, input }) => { + const userId = context.session.userId; + const { id } = input; const [updated] = await db .update(notification) @@ -136,14 +88,13 @@ export const markAsRead = notificationsFactory.createHandlers( throw notFound('Notification not found'); } - return ok(c, updated); - } -); + return updated; + }); -export const markAllAsRead = notificationsFactory.createHandlers( - ...authRouter(), - async (c) => { - const { id: userId } = getUser(c); +export const markAllAsRead = base.notifications.markAllAsRead + .use(requireAuthentication) + .handler(async ({ context }) => { + const userId = context.session.userId; await db .update(notification) @@ -152,14 +103,13 @@ export const markAllAsRead = notificationsFactory.createHandlers( sql`${notification.userId} = ${userId} AND ${notification.read} = false` ); - return ok(c, undefined); - } -); + return { ok: true as const }; + }); -export const getNotificationSettings = notificationsFactory.createHandlers( - ...authRouter(), - async (c) => { - const { id: userId } = getUser(c); +export const getNotificationSettings = base.notifications.settings + .use(requireAuthentication) + .handler(async ({ context }) => { + const userId = context.session.userId; const [prefs] = await db .select() @@ -167,7 +117,7 @@ export const getNotificationSettings = notificationsFactory.createHandlers( .where(eq(userPreferences.userId, userId)); if (prefs) { - return ok(c, prefs); + return prefs; } const [created] = await db @@ -175,16 +125,20 @@ export const getNotificationSettings = notificationsFactory.createHandlers( .values({ userId }) .returning(); - return ok(c, created); - } -); + if (!created) { + throw new ORPCError('INTERNAL', { + message: 'Failed to create user preferences', + }); + } -export const updateNotificationSettings = notificationsFactory.createHandlers( - ...authRouter(), - zValidator('json', updateSettingsSchema), - async (c) => { - const { id: userId } = getUser(c); - const body = c.req.valid('json'); + return created; + }); + +export const updateNotificationSettings = base.notifications.updateSettings + .use(requireAuthentication) + .handler(async ({ context, input }) => { + const userId = context.session.userId; + const body = input; const values: Record = {}; @@ -214,7 +168,7 @@ export const updateNotificationSettings = notificationsFactory.createHandlers( .returning(); if (updated) { - return ok(c, updated); + return updated; } const [created] = await db @@ -222,17 +176,21 @@ export const updateNotificationSettings = notificationsFactory.createHandlers( .values({ userId, ...values }) .returning(); - return ok(c, created); - } -); + if (!created) { + throw new ORPCError('INTERNAL', { + message: 'Failed to create user preferences', + }); + } + + return created; + }); -export const registerFcmToken = notificationsFactory.createHandlers( - ...authRouter(), - zValidator('json', fcmTokenSchema), - async (c) => { - const { id: userId } = getUser(c); +export const registerFcmToken = base.notifications.fcmTokens.register + .use(requireAuthentication) + .handler(async ({ context, input }) => { + const userId = context.session.userId; - const { token: fcmTokenValue } = c.req.valid('json'); + const { token: fcmTokenValue } = input; const [existing] = await db .select() @@ -243,26 +201,25 @@ export const registerFcmToken = notificationsFactory.createHandlers( if (!existing) { await db.insert(fcmToken).values({ - deviceInfo: c.req.valid('json').deviceInfo ?? null, + deviceInfo: input.deviceInfo ?? null, token: fcmTokenValue, userId, }); } - return createdResponse(c, undefined); - } -); + return { ok: true as const }; + }); -export const testNotification = notificationsFactory.createHandlers( - ...authRouter(), - async (c) => { +export const testNotification = base.notifications.test + .use(requireAuthentication) + .handler(async ({ context }) => { if (env.mode !== 'development') { - throw new HTTPException(StatusCodes.FORBIDDEN, { - message: 'Test notifications can only be sent in development mode', - }); + throw forbidden( + 'Test notifications can only be sent in development mode' + ); } - const { id: userId } = getUser(c); + const userId = context.session.userId; const [notif] = await db .insert(notification) @@ -276,7 +233,7 @@ export const testNotification = notificationsFactory.createHandlers( .returning(); if (!notif) { - throw new HTTPException(StatusCodes.INTERNAL_SERVER_ERROR, { + throw new ORPCError('INTERNAL', { message: 'Failed to create test notification', }); } @@ -303,16 +260,14 @@ export const testNotification = notificationsFactory.createHandlers( userId, }); - return ok(c, notif); - } -); + return notif; + }); -export const unregisterFcmToken = notificationsFactory.createHandlers( - ...authRouter(), - zValidator('json', tokenDeleteSchema), - async (c) => { - const { id: userId } = getUser(c); - const { token } = c.req.valid('json'); +export const unregisterFcmToken = base.notifications.fcmTokens.unregister + .use(requireAuthentication) + .handler(async ({ context, input }) => { + const userId = context.session.userId; + const { token } = input; await db .delete(fcmToken) @@ -320,102 +275,8 @@ export const unregisterFcmToken = notificationsFactory.createHandlers( sql`${fcmToken.userId} = ${userId} AND ${fcmToken.token} = ${token}` ); - return ok(c, undefined); - } -); - -export const getUnsubscribePage = notificationsFactory.createHandlers( - zValidator('query', unsubscribeSchema), - (c) => { - const { token, userId } = c.req.valid('query'); - - return c.html(` - - - - - Leiratkozás - Filc - - - -
-

Leiratkozás az értesítésekről

-

Szeretnéd lemondani az összes email értesítést a Filc rendszertől? Ezután nem fogsz több emailt kapni.

-
- - - -
-
- -`); - } -); - -export const processUnsubscribe = notificationsFactory.createHandlers( - zValidator('form', unsubscribeSchema), - async (c) => { - const { token, userId } = c.req.valid('form'); - - const expected = generateUnsubscribeToken(userId); - if (expected !== token) { - return c.html( - ` - -Hiba - Filc -

Érvénytelen leiratkozási token

A token érvénytelen vagy lejárt.

-`, - StatusCodes.BAD_REQUEST - ); - } - - const allDisabled = { - announcement: false, - blogPost: false, - channelsEnabled: false, - doorlockCardUsed: false, - movedLesson: false, - substitution: false, - systemMessage: false, - }; - - await db - .insert(userPreferences) - .values({ notificationPreferences: allDisabled, userId }) - .onConflictDoUpdate({ - set: { notificationPreferences: allDisabled }, - target: userPreferences.userId, - }); - - return c.html(` - - - - - Sikeres leiratkozás - Filc - - - -
-

Sikeres leiratkozás

-

Innentől nem fogsz email értesítéseket kapni a Filc rendszertől.

-
- -`); - } -); + return { ok: true as const }; + }); type TestTarget = { email: string; @@ -477,18 +338,17 @@ async function resolveTestTarget( return target; } -export const sendTestNotification = notificationsFactory.createHandlers( - ...authRouter(), - zValidator('json', sendTestNotificationSchema), - async (c) => { +export const sendTestNotification = base.notifications.sendTest + .use(requireAuthentication) + .handler(async ({ context, input }) => { if (env.mode !== 'development') { - throw new HTTPException(StatusCodes.FORBIDDEN, { - message: 'Test notifications can only be sent in development mode', - }); + throw forbidden( + 'Test notifications can only be sent in development mode' + ); } - const { id: currentUserId } = getUser(c); - const body = c.req.valid('json'); + const currentUserId = context.session.userId; + const body = input; const target = await resolveTestTarget( body.email, @@ -523,21 +383,19 @@ export const sendTestNotification = notificationsFactory.createHandlers( results.push = await sendPush(target.id, subject, content); } - return ok(c, results); - } -); + return results; + }); -export const previewTestNotification = notificationsFactory.createHandlers( - ...authRouter(), - zValidator('json', previewTestNotificationSchema), - async (c) => { +export const previewTestNotification = base.notifications.previewTest + .use(requireAuthentication) + .handler(async ({ input }) => { if (env.mode !== 'development') { - throw new HTTPException(StatusCodes.FORBIDDEN, { - message: 'Test notifications can only be previewed in development mode', - }); + throw forbidden( + 'Test notifications can only be previewed in development mode' + ); } - const body = c.req.valid('json'); + const body = input; const subject = body.subject ?? 'Filc test message'; const content = body.content ?? @@ -548,6 +406,5 @@ export const previewTestNotification = notificationsFactory.createHandlers( title: subject, }); - return ok(c, { html }); - } -); + return { html }; + }); diff --git a/apps/chronos/src/routes/notifications/unsubscribe-html.ts b/apps/chronos/src/routes/notifications/unsubscribe-html.ts new file mode 100644 index 00000000..305a7be7 --- /dev/null +++ b/apps/chronos/src/routes/notifications/unsubscribe-html.ts @@ -0,0 +1,138 @@ +import { unsubscribeSchema } from '@filcdev/api/domains/notifications'; +import { db } from '#database'; +import { userPreferences } from '#database/schema/notifications'; +import { generateUnsubscribeToken } from '#utils/notifications/providers/smtp'; + +/** + * `GET|POST /api/notifications/unsubscribe`: the two HTML pages the + * unsubscribe link in an email lands on. Deliberately outside the oRPC + * contract — the browser posts an HTML form and renders the result, and the + * page is the whole response. `src/index.ts` dispatches the path here before + * the RPC/OpenAPI handlers. + */ + +/** HTML-escape a string for interpolation into markup (e.g. reflected tokens). */ +const escapeHtml = (value: string): string => + value.replace(/[&<>"']/g, (ch) => { + switch (ch) { + case '&': + return '&'; + case '<': + return '<'; + case '>': + return '>'; + case '"': + return '"'; + default: + return '''; + } + }); + +export async function handleUnsubscribe(request: Request): Promise { + const params = + request.method === 'POST' + ? new URLSearchParams(await request.text()) + : new URL(request.url).searchParams; + + const parsed = unsubscribeSchema.safeParse(Object.fromEntries(params)); + + const invalidTokenHtml = ` + +Hiba - Filc +

Érvénytelen leiratkozási token

A token érvénytelen vagy lejárt.

+`; + + if (!parsed.success) { + return new Response(invalidTokenHtml, { + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + status: 400, + }); + } + + const { token, userId } = parsed.data; + + if (request.method !== 'POST') { + const html = ` + + + + + Leiratkozás - Filc + + + +
+

Leiratkozás az értesítésekről

+

Szeretnéd lemondani az összes email értesítést a Filc rendszertől? Ezután nem fogsz több emailt kapni.

+
+ + + +
+
+ +`; + + return new Response(html, { + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }); + } + + const expected = generateUnsubscribeToken(userId); + if (expected !== token) { + return new Response(invalidTokenHtml, { + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + status: 400, + }); + } + + const allDisabled = { + announcement: false, + blogPost: false, + channelsEnabled: false, + doorlockCardUsed: false, + movedLesson: false, + substitution: false, + systemMessage: false, + }; + + await db + .insert(userPreferences) + .values({ notificationPreferences: allDisabled, userId }) + .onConflictDoUpdate({ + set: { notificationPreferences: allDisabled }, + target: userPreferences.userId, + }); + + const html = ` + + + + + Sikeres leiratkozás - Filc + + + +
+

Sikeres leiratkozás

+

Innentől nem fogsz email értesítéseket kapni a Filc rendszertől.

+
+ +`; + + return new Response(html, { + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }); +} diff --git a/apps/chronos/src/routes/ping/_factory.ts b/apps/chronos/src/routes/ping/_factory.ts deleted file mode 100644 index 4fa20bdb..00000000 --- a/apps/chronos/src/routes/ping/_factory.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { createFactory } from 'hono/factory'; -import type { Context } from '#_types/globals'; - -export const pingFactory = createFactory(); diff --git a/apps/chronos/src/routes/ping/_router.ts b/apps/chronos/src/routes/ping/_router.ts index 8d81d452..ab0379fe 100644 --- a/apps/chronos/src/routes/ping/_router.ts +++ b/apps/chronos/src/routes/ping/_router.ts @@ -1,8 +1,4 @@ -import { pingFactory } from '#routes/ping/_factory'; import { ping } from '#routes/ping/index'; import { uptime } from '#routes/ping/uptime'; -export const pingRouter = pingFactory - .createApp() - .get('/', ...ping) - .get('/uptime', ...uptime); +export const pingRouter = { ping, uptime }; diff --git a/apps/chronos/src/routes/ping/index.ts b/apps/chronos/src/routes/ping/index.ts index 643f7afb..763c6998 100644 --- a/apps/chronos/src/routes/ping/index.ts +++ b/apps/chronos/src/routes/ping/index.ts @@ -1,24 +1,5 @@ -import { pingResponseSchema } from '@filcdev/api/domains/ping'; -import { describeRoute, resolver } from 'hono-openapi'; -import { pingFactory } from '#routes/ping/_factory'; -import { ok } from '#utils/http'; -import { filcExt } from '#utils/openapi'; +import { base } from '#orpc'; -export const ping = pingFactory.createHandlers( - describeRoute({ - ...filcExt('Ping', '@unit PingResponse'), - description: 'Health check endpoint that returns a pong response.', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(pingResponseSchema), - }, - }, - description: 'Successful Response', - }, - }, - tags: ['Ping'], - }), - (c) => ok(c, { message: 'pong' as const }) -); +export const ping = base.ping.ping.handler(() => ({ + message: 'pong' as const, +})); diff --git a/apps/chronos/src/routes/ping/uptime.ts b/apps/chronos/src/routes/ping/uptime.ts index beff9c1b..518307aa 100644 --- a/apps/chronos/src/routes/ping/uptime.ts +++ b/apps/chronos/src/routes/ping/uptime.ts @@ -1,38 +1,17 @@ -import { uptimeResponseSchema } from '@filcdev/api/domains/ping'; import dayjs from 'dayjs'; import duration from 'dayjs/plugin/duration'; import relativeTime from 'dayjs/plugin/relativeTime'; -import { describeRoute, resolver } from 'hono-openapi'; -import { pingFactory } from '#routes/ping/_factory'; -import { ok } from '#utils/http'; -import { filcExt } from '#utils/openapi'; +import { base } from '#orpc'; dayjs.extend(relativeTime); dayjs.extend(duration); -export const uptime = pingFactory.createHandlers( - describeRoute({ - ...filcExt('Ping', '@unit UptimeResponse'), - description: 'Get the uptime.', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(uptimeResponseSchema), - }, - }, - description: 'Successful Response', - }, - }, - tags: ['Ping'], - }), - (c) => { - const NANOSECONDS_IN_MILLISECOND = 1_000_000; - const uptime_ms = Bun.nanoseconds() / NANOSECONDS_IN_MILLISECOND; +export const uptime = base.ping.uptime.handler(() => { + const NANOSECONDS_IN_MILLISECOND = 1_000_000; + const uptime_ms = Bun.nanoseconds() / NANOSECONDS_IN_MILLISECOND; - return ok(c, { - pretty: dayjs.duration(uptime_ms, 'millisecond').humanize(), - uptime_ms, - }); - } -); + return { + pretty: dayjs.duration(uptime_ms, 'millisecond').humanize(), + uptime_ms, + }; +}); diff --git a/apps/chronos/src/routes/roles/_factory.ts b/apps/chronos/src/routes/roles/_factory.ts deleted file mode 100644 index 38c01f57..00000000 --- a/apps/chronos/src/routes/roles/_factory.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { createFactory } from 'hono/factory'; -import type { Context } from '#_types/globals'; - -export const rolesFactory = createFactory(); diff --git a/apps/chronos/src/routes/roles/_router.ts b/apps/chronos/src/routes/roles/_router.ts index 42f54e10..dd31e16c 100644 --- a/apps/chronos/src/routes/roles/_router.ts +++ b/apps/chronos/src/routes/roles/_router.ts @@ -1,4 +1,3 @@ -import { rolesFactory } from '#routes/roles/_factory'; import { createRole, deleteRole, @@ -7,10 +6,10 @@ import { updateRole, } from '#routes/roles/index'; -export const rolesRouter = rolesFactory - .createApp() - .get('/', ...listRoles) - .get('/permissions', ...listPermissions) - .post('/', ...createRole) - .patch('/:name', ...updateRole) - .delete('/:name', ...deleteRole); +export const rolesRouter = { + create: createRole, + delete: deleteRole, + list: listRoles, + permissions: listPermissions, + update: updateRole, +}; diff --git a/apps/chronos/src/routes/roles/index.ts b/apps/chronos/src/routes/roles/index.ts index e27a2802..e8033c00 100644 --- a/apps/chronos/src/routes/roles/index.ts +++ b/apps/chronos/src/routes/roles/index.ts @@ -1,44 +1,17 @@ -import { - createRoleSchema, - roleNameParamsSchema, - updateRoleSchema, -} from '@filcdev/api/domains/roles'; -import { zValidator } from '@hono/zod-validator'; +import { permissions } from '@filcdev/api/permissions'; import { SQL } from 'bun'; -import { HTTPException } from 'hono/http-exception'; -import { describeRoute } from 'hono-openapi'; -import { StatusCodes } from 'http-status-codes'; -import { authRouter } from '#middleware/auth'; -import { rolesFactory } from '#routes/roles/_factory'; +import { requireAuthorization } from '#middleware/auth'; +import { base } from '#orpc'; import { rbac } from '#utils/authorization'; -import { ApiHttpError, conflict, created, ok } from '#utils/http'; - -export const listPermissions = rolesFactory.createHandlers( - describeRoute({ - description: 'List all known permissions registered by the application', - responses: { - 200: { - description: 'List of permissions', - }, - }, - tags: ['Roles'], - }), - ...authRouter('roles:read'), - (c) => ok(c, { permissions: rbac.getAllPermissions() }) -); - -export const listRoles = rolesFactory.createHandlers( - describeRoute({ - description: 'List all roles with their permissions', - responses: { - 200: { - description: 'List of roles', - }, - }, - tags: ['Roles'], - }), - ...authRouter('roles:read'), - (c) => { +import { badRequest, conflict, notFound } from '#utils/http'; + +export const listPermissions = base.roles.permissions + .use(requireAuthorization(permissions.rolesRead)) + .handler(() => ({ permissions: rbac.getAllPermissions() })); + +export const listRoles = base.roles.list + .use(requireAuthorization(permissions.rolesRead)) + .handler(() => { const allRoles = rbac.getAllRoles(); const roles = Object.entries(allRoles).map(([name, def]) => ({ @@ -46,38 +19,22 @@ export const listRoles = rolesFactory.createHandlers( name, })); - return ok(c, { roles }); - } -); - -export const createRole = rolesFactory.createHandlers( - describeRoute({ - description: 'Create a new role', - responses: { - 201: { - description: 'Role created', - }, - }, - tags: ['Roles'], - }), - ...authRouter('roles:manage'), - zValidator('json', createRoleSchema), - async (c) => { - const { name, permissions } = c.req.valid('json'); + return { roles }; + }); + +export const createRole = base.roles.create + .use(requireAuthorization(permissions.rolesManage)) + .handler(async ({ input }) => { + const { name, permissions: rolePermissions } = input; const existingRoles = rbac.getAllRoles(); if (name in existingRoles) { - throw new HTTPException(StatusCodes.CONFLICT, { - message: `Role "${name}" already exists`, - }); + throw conflict(`Role "${name}" already exists`); } try { - await rbac.createRole(name, permissions); + await rbac.createRole(name, rolePermissions); } catch (error) { - if (error instanceof ApiHttpError) { - throw error; - } // Postgres unique-violation from a concurrent create. if (error instanceof SQL.PostgresError && error.code === '23505') { throw conflict(`Role "${name}" already exists`); @@ -85,69 +42,38 @@ export const createRole = rolesFactory.createHandlers( throw error; } - return created(c, { can: permissions, name }); - } -); - -export const updateRole = rolesFactory.createHandlers( - describeRoute({ - description: 'Update permissions for a role', - responses: { - 200: { - description: 'Role updated', - }, - }, - tags: ['Roles'], - }), - ...authRouter('roles:manage'), - zValidator('json', updateRoleSchema), - zValidator('param', roleNameParamsSchema), - async (c) => { - const { name: roleName } = c.req.valid('param'); + return { can: rolePermissions, name }; + }); + +export const updateRole = base.roles.update + .use(requireAuthorization(permissions.rolesManage)) + .handler(async ({ input }) => { + const { name: roleName, permissions: rolePermissions } = input; if (!roleName) { - throw new HTTPException(StatusCodes.BAD_REQUEST, { - message: 'Role name is required', - }); + throw badRequest('Role name is required'); } const existingRoles = rbac.getAllRoles(); if (!(roleName in existingRoles)) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: `Role "${roleName}" not found`, - }); + throw notFound(`Role "${roleName}" not found`); } - const { permissions } = c.req.valid('json'); - await rbac.setPermissions(roleName, permissions); - - return ok(c, { can: permissions, name: roleName }); - } -); - -export const deleteRole = rolesFactory.createHandlers( - describeRoute({ - description: 'Delete a role', - responses: { - 200: { - description: 'Role deleted', - }, - }, - tags: ['Roles'], - }), - ...authRouter('roles:manage'), - zValidator('param', roleNameParamsSchema), - async (c) => { - const { name: roleName } = c.req.valid('param'); + await rbac.setPermissions(roleName, rolePermissions); + + return { can: rolePermissions, name: roleName }; + }); + +export const deleteRole = base.roles.delete + .use(requireAuthorization(permissions.rolesManage)) + .handler(async ({ input }) => { + const { name: roleName } = input; const existingRoles = rbac.getAllRoles(); if (!(roleName in existingRoles)) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: `Role "${roleName}" not found`, - }); + throw notFound(`Role "${roleName}" not found`); } await rbac.deleteRole(roleName); - return ok(c, undefined); - } -); + return { ok: true as const }; + }); diff --git a/apps/chronos/src/routes/timetable/_curriculum-router.ts b/apps/chronos/src/routes/timetable/_curriculum-router.ts new file mode 100644 index 00000000..a6d56520 --- /dev/null +++ b/apps/chronos/src/routes/timetable/_curriculum-router.ts @@ -0,0 +1,64 @@ +import { exportMovedLessonsRoute, exportSubstitutionsRoute } from './export'; +import { + getLessonForId, + getLessonsForCohort, + getLessonsForRoom, + getLessonsForTeacher, + getLessonsForTeachers, + getSubstitutionCandidates, +} from './lesson'; +import { + createMovedLesson, + deleteMovedLesson, + getAllMovedLessons, + getMovedLessonsForCohort, + getRelevantMovedLessons, + getRelevantMovedLessonsForCohort, + updateMovedLesson, +} from './moved-lesson'; +import { + createManualSubstitution, + createSubstitution, + deleteSubstitution, + getAllSubstitutions, + getRelevantSubstitutions, + getRelevantSubstitutionsForCohort, + updateSubstitution, +} from './substitution'; + +/** + * The lesson/substitution/moved-lesson half of the timetable feature: the + * implementations for `lessonsContract`, `substitutionsContract` and + * `movedLessonsContract`, keyed exactly like the contract so `_router.ts` can + * spread it into the assembled timetable router. + */ +export const curriculumRouter = { + lessons: { + getForCohort: getLessonsForCohort, + getForId: getLessonForId, + getForRoom: getLessonsForRoom, + getForTeacher: getLessonsForTeacher, + getForTeachers: getLessonsForTeachers, + getSubstitutionCandidates, + }, + movedLessons: { + create: createMovedLesson, + delete: deleteMovedLesson, + export: exportMovedLessonsRoute, + forCohort: getMovedLessonsForCohort, + list: getAllMovedLessons, + relevant: getRelevantMovedLessons, + relevantForCohort: getRelevantMovedLessonsForCohort, + update: updateMovedLesson, + }, + substitutions: { + create: createSubstitution, + delete: deleteSubstitution, + export: exportSubstitutionsRoute, + list: getAllSubstitutions, + manual: createManualSubstitution, + relevant: getRelevantSubstitutions, + relevantForCohort: getRelevantSubstitutionsForCohort, + update: updateSubstitution, + }, +}; diff --git a/apps/chronos/src/routes/timetable/_factory.ts b/apps/chronos/src/routes/timetable/_factory.ts deleted file mode 100644 index 5daa3bae..00000000 --- a/apps/chronos/src/routes/timetable/_factory.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { createFactory } from 'hono/factory'; -import type { AuthenticatedContext } from '#_types/globals'; - -export const timetableFactory = createFactory(); diff --git a/apps/chronos/src/routes/timetable/_router.ts b/apps/chronos/src/routes/timetable/_router.ts index 444f5ac9..71e05a5d 100644 --- a/apps/chronos/src/routes/timetable/_router.ts +++ b/apps/chronos/src/routes/timetable/_router.ts @@ -1,11 +1,7 @@ -import { timetableFactory } from '#routes/timetable/_factory'; -import { getCohortsForTimetable } from '#routes/timetable/cohort'; -import { - exportMovedLessonsRoute, - exportSubstitutionsRoute, -} from '#routes/timetable/export'; -import { getGroupsForCohort, selectGroup } from '#routes/timetable/groups'; -import { importRoute } from '#routes/timetable/import'; +import { curriculumRouter } from './_curriculum-router'; +import { getCohortsForTimetable } from './cohort'; +import { getGroupsForCohort, selectGroup } from './groups'; +import { importRoute } from './import'; import { cleanupOrphanedCohortsHandler, deleteTimetable, @@ -14,35 +10,9 @@ import { getLatestValidTimetable, previewDeleteTimetable, updateTimetable, -} from '#routes/timetable/index'; -import { - getLessonForId, - getLessonsForCohort, - getLessonsForRoom, - getLessonsForTeacher, - getLessonsForTeachers, - getSubjects, - getSubstitutionCandidates, -} from '#routes/timetable/lesson'; -import { - createMovedLesson, - deleteMovedLesson, - getAllMovedLessons, - getMovedLessonsForCohort, - getRelevantMovedLessons, - getRelevantMovedLessonsForCohort, - updateMovedLesson, -} from '#routes/timetable/moved-lesson'; -import { getPeriodsForTimetable } from '#routes/timetable/period'; -import { - createManualSubstitution, - createSubstitution, - deleteSubstitution, - getAllSubstitutions, - getRelevantSubstitutions, - getRelevantSubstitutionsForCohort, - updateSubstitution, -} from '#routes/timetable/substitution'; +} from './index'; +import { getSubjects } from './lesson'; +import { getPeriodsForTimetable } from './period'; import { getAvailableClassrooms, getClassrooms } from './room'; import { getMyTeacher, @@ -51,61 +21,52 @@ import { updateTeacher, } from './teacher'; -export const timetableRouter = timetableFactory - .createApp() - // Timetable routes - .get('/timetables', ...getAllTimetables) - .get('/timetables/latestValid', ...getLatestValidTimetable) - .get('/timetables/valid', ...getAllValidTimetables) - .patch('/timetables/:id', ...updateTimetable) - .get('/timetables/:id/preview-delete', ...previewDeleteTimetable) - .delete('/timetables/:id', ...deleteTimetable) - .post( - '/timetables/cleanup-orphaned-cohorts', - ...cleanupOrphanedCohortsHandler - ) - .post('/import', ...importRoute) - // Substitution routes - .get('/substitutions', ...getAllSubstitutions) - .get('/substitutions/relevant', ...getRelevantSubstitutions) - .get('/substitutions/export', ...exportSubstitutionsRoute) - .get('/substitutions/cohort/:cohortId', ...getRelevantSubstitutionsForCohort) - .post('/substitutions', ...createSubstitution) - .post('/substitutions/manual', ...createManualSubstitution) - .put('/substitutions/:id', ...updateSubstitution) - .delete('/substitutions/:id', ...deleteSubstitution) - // Moved lesson routes - .get('/movedLessons', ...getAllMovedLessons) - .get('/movedLessons/relevant', ...getRelevantMovedLessons) - .get('/movedLessons/export', ...exportMovedLessonsRoute) - .get('/movedLessons/cohort/:cohortId', ...getMovedLessonsForCohort) - .get( - '/movedLessons/cohort/:cohortId/relevant', - ...getRelevantMovedLessonsForCohort - ) - .post('/movedLessons', ...createMovedLesson) - .put('/movedLessons/:id', ...updateMovedLesson) - .delete('/movedLessons/:id', ...deleteMovedLesson) - // Lesson routes - .get('/lessons/getForCohort/:cohortId', ...getLessonsForCohort) - .get('/lessons/getForTeacher/:teacherId', ...getLessonsForTeacher) - .post('/lessons/getForTeachers', ...getLessonsForTeachers) - .post('/lessons/getSubstitutionCandidates', ...getSubstitutionCandidates) - .get('/lessons/getForRoom/:classroomId', ...getLessonsForRoom) - .get('/lessons/getForId/:lessonId', ...getLessonForId) - .get('/subjects', ...getSubjects) - // Period routes - .get('/periods/getAll', ...getPeriodsForTimetable) - // Classroom routes - .get('/classrooms/getAvailable', ...getAvailableClassrooms) - .get('/classrooms/getAll', ...getClassrooms) - // Cohort routes - .get('/cohorts/getAllForTimetable/:timetableId', ...getCohortsForTimetable) - // Group routes - .get('/groups/getForCohort/:cohortId', ...getGroupsForCohort) - .post('/groups/select', ...selectGroup) - // Teacher routes - .get('/teachers/getAll', ...getTeachers) - .get('/teachers', ...listTeachersAdmin) - .get('/teachers/me', ...getMyTeacher) - .patch('/teachers/:id', ...updateTeacher); +const timetables = { + timetables: { + cleanupOrphanedCohorts: cleanupOrphanedCohortsHandler, + delete: deleteTimetable, + latestValid: getLatestValidTimetable, + list: getAllTimetables, + previewDelete: previewDeleteTimetable, + update: updateTimetable, + valid: getAllValidTimetables, + }, +}; + +const importRouter = { import: importRoute }; + +const peopleRouter = { + classrooms: { + getAll: getClassrooms, + getAvailable: getAvailableClassrooms, + }, + cohorts: { + getAllForTimetable: getCohortsForTimetable, + }, + groups: { + getForCohort: getGroupsForCohort, + select: selectGroup, + }, + periods: { + getAll: getPeriodsForTimetable, + }, + subjects: getSubjects, + teachers: { + getAll: getTeachers, + list: listTeachersAdmin, + me: getMyTeacher, + update: updateTeacher, + }, +}; + +/** + * Every `timetable.*` procedure, mirroring the contract's nesting: the + * timetables themselves, the XML import, the people and rooms behind them, and + * the curriculum taught in them. + */ +export const timetableRouter = { + ...timetables, + ...importRouter, + ...peopleRouter, + ...curriculumRouter, +}; diff --git a/apps/chronos/src/routes/timetable/cohort.ts b/apps/chronos/src/routes/timetable/cohort.ts index bca2bbf0..e0dc014f 100644 --- a/apps/chronos/src/routes/timetable/cohort.ts +++ b/apps/chronos/src/routes/timetable/cohort.ts @@ -1,50 +1,11 @@ -import { getCohortsForTimetableParamsSchema } from '@filcdev/api/domains/timetable/cohort'; -import { zValidator } from '@hono/zod-validator'; import { eq } from 'drizzle-orm'; -import { describeRoute, resolver } from 'hono-openapi'; -import z from 'zod'; import { db } from '#database'; import { cohort, cohortTimetableMtm } from '#database/schema/timetable'; -import { ok } from '#utils/http'; -import { filcExt } from '#utils/openapi'; -import { createSelectSchema } from '#utils/zod'; -import { timetableFactory } from './_factory'; +import { base } from '#orpc'; -const getForTimetableResponseSchema = z.object({ - data: createSelectSchema(cohort).array(), - success: z.boolean(), -}); - -export const getCohortsForTimetable = timetableFactory.createHandlers( - describeRoute({ - ...filcExt('Cohort', '@listof Cohort', true), - description: 'Get cohorts for a given timetable from the database.', - parameters: [ - { - in: 'path', - name: 'timetableId', - required: true, - schema: { - description: 'The unique identifier for the timetable.', - type: 'string', - }, - }, - ], - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(getForTimetableResponseSchema), - }, - }, - description: 'Successful Response', - }, - }, - tags: ['Cohort'], - }), - zValidator('param', getCohortsForTimetableParamsSchema), - async (c) => { - const { timetableId } = c.req.valid('param'); +export const getCohortsForTimetable = + base.timetable.cohorts.getAllForTimetable.handler(async ({ input }) => { + const { timetableId } = input; const cohortRows = await db .select() @@ -54,6 +15,5 @@ export const getCohortsForTimetable = timetableFactory.createHandlers( const cohorts = cohortRows.map((r) => r.cohort); - return ok(c, cohorts); - } -); + return cohorts; + }); diff --git a/apps/chronos/src/routes/timetable/export.ts b/apps/chronos/src/routes/timetable/export.ts index 6b9a99b8..c204fffa 100644 --- a/apps/chronos/src/routes/timetable/export.ts +++ b/apps/chronos/src/routes/timetable/export.ts @@ -1,10 +1,6 @@ -import { - type DateRangeQueryInput, - dateRangeQuerySchema, -} from '@filcdev/api/domains/timetable/export'; +import type { DateRangeQueryInput } from '@filcdev/api/domains/timetable/export'; import { permissions } from '@filcdev/api/permissions'; import { and, desc, eq, gte, inArray, lte, type SQL } from 'drizzle-orm'; -import { describeRoute } from 'hono-openapi'; import { db } from '#database'; import { classroom, @@ -21,8 +17,7 @@ import { teacher, } from '#database/schema/timetable'; import { requireAuthentication, requireAuthorization } from '#middleware/auth'; -import { filcExt } from '#utils/openapi'; -import { timetableFactory } from './_factory'; +import { base } from '#orpc'; const buildDateFilters = ( filters: SQL[], @@ -61,29 +56,11 @@ const toCsv = (header: string[], rows: Record[]): string => { const buildFilename = (prefix: string): string => `${prefix}-export-${new Date().toISOString().slice(0, 10)}.csv`; -export const exportSubstitutionsRoute = timetableFactory.createHandlers( - describeRoute({ - ...filcExt('Substitution', 'Export substitutions as CSV', true), - description: - 'Export substitutions as a CSV file over an optional date range.', - responses: { - 200: { - content: { - 'text/csv': { - schema: { format: 'binary', type: 'string' }, - }, - }, - description: 'CSV file with the requested substitutions', - }, - }, - tags: ['Substitution'], - }), - requireAuthentication, - requireAuthorization(permissions.substitutionCreate), - async (c) => { - const url = new URL(c.req.url); - const queryParams = Object.fromEntries(url.searchParams.entries()); - const query = dateRangeQuerySchema.parse(queryParams); +export const exportSubstitutionsRoute = base.timetable.substitutions.export + .use(requireAuthentication) + .use(requireAuthorization(permissions.substitutionCreate)) + .handler(async ({ input }) => { + const query = input; const filters: SQL[] = []; buildDateFilters( @@ -191,35 +168,14 @@ export const exportSubstitutionsRoute = timetableFactory.createHandlers( const csv = toCsv(header, rows); const filename = buildFilename('substitutions'); - c.header('Content-Type', 'text/csv; charset=utf-8'); - c.header('Content-Disposition', `attachment; filename="${filename}"`); - return c.body(csv); - } -); - -export const exportMovedLessonsRoute = timetableFactory.createHandlers( - describeRoute({ - ...filcExt('MovedLesson', 'Export moved lessons as CSV', true), - description: - 'Export moved lessons as a CSV file over an optional date range.', - responses: { - 200: { - content: { - 'text/csv': { - schema: { format: 'binary', type: 'string' }, - }, - }, - description: 'CSV file with the requested moved lessons', - }, - }, - tags: ['Moved Lesson'], - }), - requireAuthentication, - requireAuthorization(permissions.movedLessonCreate), - async (c) => { - const url = new URL(c.req.url); - const queryParams = Object.fromEntries(url.searchParams.entries()); - const query = dateRangeQuerySchema.parse(queryParams); + return new File([csv], filename, { type: 'text/csv; charset=utf-8' }); + }); + +export const exportMovedLessonsRoute = base.timetable.movedLessons.export + .use(requireAuthentication) + .use(requireAuthorization(permissions.movedLessonCreate)) + .handler(async ({ input }) => { + const query = input; const filters: SQL[] = []; buildDateFilters( @@ -330,8 +286,5 @@ export const exportMovedLessonsRoute = timetableFactory.createHandlers( const csv = toCsv(header, rows); const filename = buildFilename('moved-lessons'); - c.header('Content-Type', 'text/csv; charset=utf-8'); - c.header('Content-Disposition', `attachment; filename="${filename}"`); - return c.body(csv); - } -); + return new File([csv], filename, { type: 'text/csv; charset=utf-8' }); + }); diff --git a/apps/chronos/src/routes/timetable/groups.ts b/apps/chronos/src/routes/timetable/groups.ts index d223f2d8..12619113 100644 --- a/apps/chronos/src/routes/timetable/groups.ts +++ b/apps/chronos/src/routes/timetable/groups.ts @@ -1,60 +1,15 @@ -import { - getGroupsForCohortParamsSchema, - groupResponseSchema, - selectGroupRequestSchema, - selectGroupResponseSchema, -} from '@filcdev/api/domains/timetable/groups'; import { deriveDivisionLabel } from '@filcdev/timetable-import/types'; -import { zValidator } from '@hono/zod-validator'; import { and, eq, inArray } from 'drizzle-orm'; -import { HTTPException } from 'hono/http-exception'; -import { describeRoute, resolver } from 'hono-openapi'; -import { StatusCodes } from 'http-status-codes'; -import z from 'zod'; import { db } from '#database'; import { cohort, cohortGroup, userGroup } from '#database/schema/timetable'; import { requireAuthentication } from '#middleware/auth'; -import { ok } from '#utils/http'; -import { filcExt } from '#utils/openapi'; -import { timetableFactory } from './_factory'; +import { base } from '#orpc'; +import { badRequest, forbidden, notFound } from '#utils/http'; -const getForCohortResponseSchema = z.object({ - data: groupResponseSchema.array(), - success: z.boolean(), -}); - -export const getGroupsForCohort = timetableFactory.createHandlers( - describeRoute({ - ...filcExt('Group', '@listof Group', true), - description: - 'Get the groups of a cohort, marking the current user selection.', - parameters: [ - { - in: 'path', - name: 'cohortId', - required: true, - schema: { - description: 'The unique identifier for the cohort.', - type: 'string', - }, - }, - ], - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(getForCohortResponseSchema), - }, - }, - description: 'Successful Response', - }, - }, - tags: ['Group'], - }), - zValidator('param', getGroupsForCohortParamsSchema), - async (c) => { - const { cohortId } = c.req.valid('param'); - const userId = c.get('user')?.id ?? null; +export const getGroupsForCohort = base.timetable.groups.getForCohort.handler( + async ({ input, context }) => { + const { cohortId } = input; + const userId = context.user?.id ?? null; const [existingCohort] = await db .select() @@ -63,9 +18,7 @@ export const getGroupsForCohort = timetableFactory.createHandlers( .limit(1); if (!existingCohort) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: 'Cohort not found', - }); + throw notFound('Cohort not found'); } const rows = await db @@ -114,37 +67,16 @@ export const getGroupsForCohort = timetableFactory.createHandlers( : null, selected: selected.has(row.id), })); - return ok(c, data); + return data; } ); -export const selectGroup = timetableFactory.createHandlers( - describeRoute({ - ...filcExt( - 'Group', - '@object Group @field(.selectedGroupId, string) @field(.divisionTag, string)', - true - ), - description: - 'Select the group the current user belongs to for a division. Replaces any previous membership in that division.', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(selectGroupResponseSchema), - }, - }, - description: 'Successful Response', - }, - }, - tags: ['Group'], - }), - requireAuthentication, - zValidator('json', selectGroupRequestSchema), - async (c) => { - const user = c.get('user'); - const userId = user.id; - const { groupId } = c.req.valid('json'); +export const selectGroup = base.timetable.groups.select + .use(requireAuthentication) + .handler(async ({ input, context }) => { + const userId = context.session.userId; + const userCohortId = context.user?.cohortId ?? null; + const { groupId } = input; const [group] = await db .select() @@ -153,20 +85,14 @@ export const selectGroup = timetableFactory.createHandlers( .limit(1); if (!group) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: 'Group not found', - }); + throw notFound('Group not found'); } if (!(group.cohortId && group.divisionTag)) { - throw new HTTPException(StatusCodes.BAD_REQUEST, { - message: 'This group has no division to select', - }); + throw badRequest('This group has no division to select'); } // A caller can only pick a group in their own persisted cohort. - if (group.cohortId !== user.cohortId) { - throw new HTTPException(StatusCodes.FORBIDDEN, { - message: 'This group does not belong to your cohort', - }); + if (group.cohortId !== userCohortId) { + throw forbidden('This group does not belong to your cohort'); } const cohortId = group.cohortId; const divisionTag = group.divisionTag; @@ -205,9 +131,8 @@ export const selectGroup = timetableFactory.createHandlers( .onConflictDoNothing(); }); - return ok(c, { + return { divisionTag, selectedGroupId: groupId, - }); - } -); + }; + }); diff --git a/apps/chronos/src/routes/timetable/import.ts b/apps/chronos/src/routes/timetable/import.ts index 185c2fe1..f46730f0 100644 --- a/apps/chronos/src/routes/timetable/import.ts +++ b/apps/chronos/src/routes/timetable/import.ts @@ -1,8 +1,5 @@ import type { ImportTimetableInput } from '@filcdev/api/domains/timetable/import'; -import { - importResponseSchema, - importSchema, -} from '@filcdev/api/domains/timetable/import'; +import { permissions } from '@filcdev/api/permissions'; import { findTimetableImportAdapter, listTimetableImportAdapters, @@ -12,16 +9,13 @@ import { asc2012TimetableImportAdapter } from '@filcdev/timetable-import/asc2012 import { importTimetable } from '@filcdev/timetable-import/import'; import { omanTimetableImportAdapter } from '@filcdev/timetable-import/oman'; import type { TimetableImportModel } from '@filcdev/timetable-import/types'; -import { zValidator } from '@hono/zod-validator'; import { getLogger } from '@logtape/logtape'; -import { HTTPException } from 'hono/http-exception'; -import { describeRoute, resolver } from 'hono-openapi'; -import { StatusCodes } from 'http-status-codes'; +import { ORPCError } from '@orpc/server'; import { timetableImportStore } from '#database/timetable-import-store'; -import { authRouter } from '#middleware/auth'; -import { timetableFactory } from '#routes/timetable/_factory'; +import { requireAuthorization } from '#middleware/auth'; +import { base } from '#orpc'; import { env } from '#utils/environment'; -import { ok } from '#utils/http'; +import { badRequest } from '#utils/http'; const logger = getLogger(['chronos', 'timetable']); @@ -41,8 +35,11 @@ const runImport = async (body: ImportTimetableInput): Promise => { const { file, name, validFrom, validTo } = body; if (file.size > MAX_IMPORT_BYTES) { - throw new HTTPException(StatusCodes.REQUEST_TOO_LONG, { + // The pre-migration handler answered 413; the shared error map has no + // request-too-long code, so the status stays explicit. + throw new ORPCError('BAD_REQUEST', { message: 'The uploaded timetable file is too large.', + status: 413, }); } @@ -56,9 +53,7 @@ const runImport = async (body: ImportTimetableInput): Promise => { candidate.detect?.(bytes) ); if (!adapter) { - throw new HTTPException(StatusCodes.BAD_REQUEST, { - message: 'Unsupported file type', - }); + throw badRequest('Unsupported file type'); } let model: TimetableImportModel; @@ -66,10 +61,10 @@ const runImport = async (body: ImportTimetableInput): Promise => { model = adapter.parse(bytes, logger); } catch (e) { logger.error('Failed to parse XML', { error: e }); - throw new HTTPException(StatusCodes.BAD_REQUEST, { - cause: env.mode === 'development' ? e : undefined, - message: 'Failed to parse XML', - }); + throw badRequest( + 'Failed to parse XML', + env.mode === 'development' ? e : undefined + ); } try { @@ -92,42 +87,19 @@ const runImport = async (body: ImportTimetableInput): Promise => { }); } catch (e) { logger.error('Failed to import timetable', { error: e }); - throw new HTTPException(StatusCodes.INTERNAL_SERVER_ERROR, { + throw new ORPCError('INTERNAL', { cause: env.mode === 'development' ? e : undefined, message: 'Failed to import timetable', }); } }; -export const importRoute = timetableFactory.createHandlers( - describeRoute({ - description: 'Import a timetable from an Oman or aSc 2012 XML file.', - requestBody: { - content: { - 'multipart/form-data': { - schema: (await resolver(importSchema).toOpenAPISchema()).schema, - }, - }, - description: 'The data for the new timetable.', - }, - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(importResponseSchema), - }, - }, - description: 'Successful Response', - }, - }, - tags: ['Timetable', 'Import'], - }), - // Authenticate before the form validator so unauthenticated requests cannot - // force the whole uploaded file into memory (z.file() is unbounded). - ...authRouter('import:timetable'), - zValidator('form', importSchema), - async (c) => { - await runImport(c.req.valid('form')); - return ok(c, undefined); - } -); +export const importRoute = base.timetable.import + // Authorize before the upload is parsed and persisted: an unauthenticated + // caller must not be able to trigger the import work. + .use(requireAuthorization(permissions.importTimetable)) + .handler(async ({ input }) => { + await runImport(input); + + return { ok: true } as const; + }); diff --git a/apps/chronos/src/routes/timetable/index.ts b/apps/chronos/src/routes/timetable/index.ts index 9d778269..d5d0d20f 100644 --- a/apps/chronos/src/routes/timetable/index.ts +++ b/apps/chronos/src/routes/timetable/index.ts @@ -1,16 +1,6 @@ -import { - cleanupOrphanedCohortsResponseSchema, - deleteTimetableResponseSchema, - previewDeleteResponseSchema, - timetableIdParamsSchema, - updateTimetableSchema, -} from '@filcdev/api/domains/timetable/timetables'; -import { zValidator } from '@hono/zod-validator'; +import { permissions } from '@filcdev/api/permissions'; +import { ORPCError } from '@orpc/server'; import { and, count, eq, gte, inArray, isNull, lte, ne, or } from 'drizzle-orm'; -import { HTTPException } from 'hono/http-exception'; -import { describeRoute, resolver } from 'hono-openapi'; -import { StatusCodes } from 'http-status-codes'; -import z from 'zod'; import { db } from '#database'; import { user } from '#database/schema/authentication'; import { @@ -23,74 +13,28 @@ import { substitutionLessonMTM, timetable, } from '#database/schema/timetable'; -import { authRouter } from '#middleware/auth'; -import { ok } from '#utils/http'; +import { requireAuthorization } from '#middleware/auth'; +import { base } from '#orpc'; +import { badRequest, notFound } from '#utils/http'; import { dispatchImmediateNotification } from '#utils/notifications/engine'; -import { filcExt } from '#utils/openapi'; import { getActiveTimetableId } from '#utils/timetable/active'; import { cleanupOrphanedCohorts } from '#utils/timetable/cleanup'; import { dateToYYYYMMDD } from '#utils/timetable/date'; import { remapSubstitutionLessonsToTimetable } from '#utils/timetable/remap-substitutions'; -import { createSelectSchema } from '#utils/zod'; -import { timetableFactory } from './_factory'; - -const timetableSelectSchema = createSelectSchema(timetable); - -const getAllResponseSchema = z.object({ - data: z.array(timetableSelectSchema), - success: z.literal(true), -}); - -const getLatestValidReponseSchema = z.object({ - data: timetableSelectSchema, - success: z.literal(true), -}); - -export const getAllTimetables = timetableFactory.createHandlers( - describeRoute({ - ...filcExt('Timetable', '@listof Timetable', true), - description: 'Get all timetables from the database.', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(getAllResponseSchema), - }, - }, - description: 'Successful Response', - }, - }, - tags: ['Timetable'], - }), - async (c) => { + +export const getAllTimetables = base.timetable.timetables.list.handler( + async () => { const timetables = await db.select().from(timetable); - return ok(c, timetables); + return timetables; } ); -export const getLatestValidTimetable = timetableFactory.createHandlers( - describeRoute({ - ...filcExt('Timetable', '@unit Timetable', true), - description: 'Get the latest valid timetable.', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(getLatestValidReponseSchema), - }, - }, - description: 'Successful Response', - }, - }, - tags: ['Timetable'], - }), - async (c) => { +export const getLatestValidTimetable = + base.timetable.timetables.latestValid.handler(async () => { const activeId = await getActiveTimetableId(); if (!activeId) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: 'No valid timetable found.', - }); + throw notFound('No valid timetable found.'); } const [latestValidTimetable] = await db @@ -100,32 +44,14 @@ export const getLatestValidTimetable = timetableFactory.createHandlers( .limit(1); if (!latestValidTimetable) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: 'No valid timetable found.', - }); + throw notFound('No valid timetable found.'); } - return ok(c, latestValidTimetable); - } -); + return latestValidTimetable; + }); -export const getAllValidTimetables = timetableFactory.createHandlers( - describeRoute({ - ...filcExt('Timetable', '@listof Timetable', true), - description: 'Get all the latest valid timetables.', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(getAllResponseSchema), - }, - }, - description: 'Successful Response', - }, - }, - tags: ['Timetable'], - }), - async (c) => { +export const getAllValidTimetables = base.timetable.timetables.valid.handler( + async () => { const today = dateToYYYYMMDD(new Date()); const timetables = await db @@ -138,37 +64,14 @@ export const getAllValidTimetables = timetableFactory.createHandlers( ) ); - return ok(c, timetables); + return timetables; } ); -const updateTimetableResponseSchema = z.object({ - data: timetableSelectSchema, - success: z.literal(true), -}); - -export const updateTimetable = timetableFactory.createHandlers( - describeRoute({ - ...filcExt('Timetable', '@unit Timetable', true), - description: 'Update a timetable validity dates.', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(updateTimetableResponseSchema), - }, - }, - description: 'Successful Response', - }, - }, - tags: ['Timetable'], - }), - zValidator('param', timetableIdParamsSchema), - zValidator('json', updateTimetableSchema), - ...authRouter('import:timetable'), - async (c) => { - const { id } = c.req.valid('param'); - const body = c.req.valid('json'); +export const updateTimetable = base.timetable.timetables.update + .use(requireAuthorization(permissions.importTimetable)) + .handler(async ({ input }) => { + const { id, ...body } = input; const [existing] = await db .select() @@ -177,9 +80,7 @@ export const updateTimetable = timetableFactory.createHandlers( .limit(1); if (!existing) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: 'Timetable not found', - }); + throw notFound('Timetable not found'); } const [updated] = await db @@ -193,49 +94,18 @@ export const updateTimetable = timetableFactory.createHandlers( .returning(); if (!updated) { - throw new HTTPException(StatusCodes.INTERNAL_SERVER_ERROR, { + throw new ORPCError('INTERNAL', { message: 'Failed to update timetable', }); } - return ok(c, updated); - } -); + return updated; + }); -export const deleteTimetable = timetableFactory.createHandlers( - describeRoute({ - ...filcExt('Timetable', '@unit Timetable', true), - description: - 'Delete a timetable and all its related data, including orphaned cohorts.', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(deleteTimetableResponseSchema), - }, - }, - description: 'Successful Response', - }, - 400: { - content: { - 'application/json': { - schema: resolver( - z.object({ - error: z.string(), - success: z.literal(false), - }) - ), - }, - }, - description: 'Cannot delete active timetable', - }, - }, - tags: ['Timetable'], - }), - zValidator('param', timetableIdParamsSchema), - ...authRouter('import:timetable'), - async (c) => { - const { id } = c.req.valid('param'); +export const deleteTimetable = base.timetable.timetables.delete + .use(requireAuthorization(permissions.importTimetable)) + .handler(async ({ input }) => { + const { id } = input; const [existing] = await db .select() @@ -244,17 +114,13 @@ export const deleteTimetable = timetableFactory.createHandlers( .limit(1); if (!existing) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: 'Timetable not found', - }); + throw notFound('Timetable not found'); } const activeId = await getActiveTimetableId(); if (activeId === id) { - throw new HTTPException(StatusCodes.BAD_REQUEST, { - message: 'Cannot delete the currently active timetable.', - }); + throw badRequest('Cannot delete the currently active timetable.'); } const notifiedUserIds: string[] = []; @@ -270,11 +136,10 @@ export const deleteTimetable = timetableFactory.createHandlers( ); if (remapResult.unmatchedSourceLessonIds.length > 0) { - throw new HTTPException(StatusCodes.BAD_REQUEST, { - message: - `Cannot delete timetable: ${remapResult.unmatchedSourceLessonIds.length} substituted lesson(s) ` + - 'could not be uniquely matched to the active timetable.', - }); + throw badRequest( + `Cannot delete timetable: ${remapResult.unmatchedSourceLessonIds.length} substituted lesson(s) ` + + 'could not be uniquely matched to the active timetable.' + ); } } else { const [linkedSubstitution] = await tx @@ -287,10 +152,9 @@ export const deleteTimetable = timetableFactory.createHandlers( .limit(1); if (linkedSubstitution) { - throw new HTTPException(StatusCodes.BAD_REQUEST, { - message: - 'Cannot delete timetable with substitutions because there is no active timetable to migrate them to.', - }); + throw badRequest( + 'Cannot delete timetable with substitutions because there is no active timetable to migrate them to.' + ); } } @@ -336,31 +200,13 @@ export const deleteTimetable = timetableFactory.createHandlers( dispatchImmediateNotification('cohort_reselection_required', { userId }); } - return ok(c, undefined); - } -); + return { id }; + }); -export const previewDeleteTimetable = timetableFactory.createHandlers( - describeRoute({ - ...filcExt('Timetable', '@unit Timetable', true), - description: - 'Preview the impact of deleting a timetable. Orphaned cohorts will be deleted along with the timetable.', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(previewDeleteResponseSchema), - }, - }, - description: 'Preview data', - }, - }, - tags: ['Timetable'], - }), - zValidator('param', timetableIdParamsSchema), - ...authRouter('import:timetable'), - async (c) => { - const { id } = c.req.valid('param'); +export const previewDeleteTimetable = base.timetable.timetables.previewDelete + .use(requireAuthorization(permissions.importTimetable)) + .handler(async ({ input }) => { + const { id } = input; const [existing] = await db .select() @@ -369,9 +215,7 @@ export const previewDeleteTimetable = timetableFactory.createHandlers( .limit(1); if (!existing) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: 'Timetable not found', - }); + throw notFound('Timetable not found'); } const activeId = await getActiveTimetableId(); @@ -464,7 +308,7 @@ export const previewDeleteTimetable = timetableFactory.createHandlers( .where(eq(lesson.timetableId, id)); const substitutionIds = [...new Set(substitutionRows.map((r) => r.id))]; - return ok(c, { + return { cohorts: cohortResults, isCurrentTimetable, targetTimetable, @@ -476,31 +320,14 @@ export const previewDeleteTimetable = timetableFactory.createHandlers( substitutionsDeleted: substitutionIds.length, survivingCohorts: cohortResults.length - orphanedCount, }, - }); - } -); + }; + }); -export const cleanupOrphanedCohortsHandler = timetableFactory.createHandlers( - describeRoute({ - ...filcExt('Timetable', '@unit Timetable', true), - description: - 'Delete all cohorts that are no longer linked to any timetable (orphaned) and all teachers that are not assigned to any lesson. Users referencing those cohorts will have their cohortId nullified. Teachers still referenced by cohorts, cohort groups, or substitutions are kept.', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(cleanupOrphanedCohortsResponseSchema), - }, - }, - description: 'Cleanup summary', - }, - }, - tags: ['Timetable'], - }), - ...authRouter('import:timetable'), - async (c) => { - const result = await cleanupOrphanedCohorts(); - - return ok(c, result); - } -); +export const cleanupOrphanedCohortsHandler = + base.timetable.timetables.cleanupOrphanedCohorts + .use(requireAuthorization(permissions.importTimetable)) + .handler(async () => { + const result = await cleanupOrphanedCohorts(); + + return result; + }); diff --git a/apps/chronos/src/routes/timetable/lesson.ts b/apps/chronos/src/routes/timetable/lesson.ts index 2d3d6bc6..7aabb3ca 100644 --- a/apps/chronos/src/routes/timetable/lesson.ts +++ b/apps/chronos/src/routes/timetable/lesson.ts @@ -1,15 +1,5 @@ -import { - getLessonForIdParamsSchema, - getLessonsForCohortParamsSchema, - getLessonsForRoomParamsSchema, - getLessonsForTeacherParamsSchema, - getLessonsQuerySchema, - substitutionCandidateSchema, - substitutionCandidatesRequestSchema, - teacherLessonsBatchRequestSchema, -} from '@filcdev/api/domains/timetable/lesson'; -import { zValidator } from '@hono/zod-validator'; import { getLogger } from '@logtape/logtape'; +import { ORPCError } from '@orpc/server'; import { and, arrayContains, @@ -18,11 +8,6 @@ import { inArray, or, } from 'drizzle-orm'; -import { HTTPException } from 'hono/http-exception'; -import { describeRoute, resolver } from 'hono-openapi'; -import { StatusCodes } from 'http-status-codes'; -import z from 'zod'; -import type { SuccessResponse } from '#_types/globals'; import { db } from '#database'; import { classroom, @@ -37,8 +22,8 @@ import { teacher, weekDefinition, } from '#database/schema/timetable'; -import { ok } from '#utils/http'; -import { filcExt } from '#utils/openapi'; +import { base } from '#orpc'; +import { notFound } from '#utils/http'; import { getActiveTimetableId, getTimetableIdForDate, @@ -47,8 +32,6 @@ import { getWeekdayInBudapest, isMatchingWeekday, } from '#utils/timetable/weekday'; -import { createSelectSchema } from '#utils/zod'; -import { timetableFactory } from './_factory'; const logger = getLogger(['chronos', 'lesson']); @@ -251,102 +234,9 @@ async function enrichLessons(lessons: (typeof lesson.$inferSelect)[]) { }); } -const enrichedLessonSchema = z.object({ - classrooms: z.array( - z.object({ id: z.string(), name: z.string(), short: z.string() }) - ), - cohorts: z.array( - z.object({ id: z.string(), name: z.string(), short: z.string() }) - ), - day: createSelectSchema(dayDefinition).optional(), - groups: z.array( - z.object({ - divisionTag: z.string().nullable(), - entireClass: z.boolean(), - id: z.string(), - name: z.string(), - }) - ), - groupsIds: z.array(z.string()), - id: z.string(), - period: z - .object({ - endTime: z.string(), - id: z.string(), - period: z.number(), - startTime: z.string(), - }) - .nullable(), - periodsPerWeek: z.number(), - subject: z - .object({ id: z.string(), name: z.string(), short: z.string() }) - .nullable(), - teachers: z.array( - z.object({ id: z.string(), name: z.string(), short: z.string() }) - ), - termDefinitionId: z.string().nullable(), - weekDefinition: z - .object({ - id: z.string(), - name: z.string(), - short: z.string(), - weeks: z.array(z.string()), - }) - .nullable(), - weeksDefinitionId: z.string(), -}); - -const responseSchema = z.object({ - data: enrichedLessonSchema.array(), - success: z.boolean(), -}); - -const enrichedLessonType = - '@listof EnrichedLesson @field(.classrooms, List) @field(.cohorts, List) @field(.day, DayDefinition) @field(.period, Period) @field(.subject, Subject) @field(.teachers, List)'; - -export const getLessonsForCohort = timetableFactory.createHandlers( - describeRoute({ - ...filcExt('Lesson', enrichedLessonType), - description: 'Get lessons for a given cohort from the database.', - parameters: [ - { - in: 'path', - name: 'cohortId', - required: true, - schema: { - description: 'The unique identifier for the cohort.', - type: 'string', - }, - }, - { - in: 'query', - name: 'timetableId', - required: false, - schema: { - description: - 'Optional timetable ID to filter lessons to a single timetable.', - format: 'uuid', - type: 'string', - }, - }, - ], - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(responseSchema), - }, - }, - description: 'Successful Response', - }, - }, - tags: ['Lesson'], - }), - zValidator('param', getLessonsForCohortParamsSchema), - zValidator('query', getLessonsQuerySchema), - async (c) => { - const { cohortId } = c.req.valid('param'); - const { timetableId } = c.req.valid('query'); +export const getLessonsForCohort = base.timetable.lessons.getForCohort.handler( + async ({ input }) => { + const { cohortId, timetableId } = input; const [existingCohort] = await db .select() @@ -355,16 +245,14 @@ export const getLessonsForCohort = timetableFactory.createHandlers( .limit(1); if (!existingCohort) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: 'Cohort not found', - }); + throw notFound('Cohort not found'); } const effectiveTimetableId = timetableId ?? (await getActiveTimetableId()); // No active timetable: yield no lessons rather than every timetable. if (!effectiveTimetableId) { - return ok(c, []); + return []; } const whereClause = and( @@ -381,47 +269,18 @@ export const getLessonsForCohort = timetableFactory.createHandlers( const lessons = lessonRows.map((r) => r.lesson); if (lessons.length === 0) { - return ok(c, []); + return []; } const enriched = await enrichLessons(lessons); - return ok(c, enriched); + return enriched; } ); -export const getLessonsForTeacher = timetableFactory.createHandlers( - describeRoute({ - ...filcExt('Lesson', enrichedLessonType), - description: 'Get lessons for a given teacher from the database.', - parameters: [ - { - in: 'path', - name: 'teacherId', - required: true, - schema: { - description: 'The unique identifier for the teacher.', - type: 'string', - }, - }, - ], - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(responseSchema), - }, - }, - description: 'Successful Response', - }, - }, - tags: ['Lesson'], - }), - zValidator('param', getLessonsForTeacherParamsSchema), - zValidator('query', getLessonsQuerySchema), - async (c) => { - const { teacherId } = c.req.valid('param'); - const { timetableId } = c.req.valid('query'); +export const getLessonsForTeacher = + base.timetable.lessons.getForTeacher.handler(async ({ input }) => { + const { teacherId, timetableId } = input; const [existingTeacher] = await db .select() @@ -430,16 +289,14 @@ export const getLessonsForTeacher = timetableFactory.createHandlers( .limit(1); if (!existingTeacher) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: 'Teacher not found', - }); + throw notFound('Teacher not found'); } const effectiveTimetableId = timetableId ?? (await getActiveTimetableId()); // No active timetable: yield no lessons rather than every timetable. if (!effectiveTimetableId) { - return ok(c, []); + return []; } const whereClause = and( @@ -450,39 +307,13 @@ export const getLessonsForTeacher = timetableFactory.createHandlers( const lessons = await db.select().from(lesson).where(whereClause); if (lessons.length === 0) { - return ok(c, []); + return []; } const enriched = await enrichLessons(lessons); - return ok(c, enriched); - } -); - -const teacherLessonsBatchResponseSchema = z.object({ - data: z.array( - z.object({ - lessons: enrichedLessonSchema.array(), - teacherId: z.uuid(), - }) - ), - success: z.boolean(), -}); - -const teacherLessonBatchType = - '@listof TeacherLessonsBatchResult @field(.teacherId, String) @field(.lessons, List)'; - -const substitutionCandidatesResponseSchema = z.object({ - data: z.object({ - availableLessons: enrichedLessonSchema.array(), - parallelLessons: enrichedLessonSchema.array(), - substituteCandidates: substitutionCandidateSchema.array(), - }), - success: z.literal(true), -}); - -const substitutionCandidatesType = - '@unit SubstitutionCandidatesResult @field(.availableLessons, List) @field(.parallelLessons, List) @field(.substituteCandidates, List)'; + return enriched; + }); type CandidateLessonEntry = { period: number; @@ -685,33 +516,9 @@ async function getParallelLessons( ); } -export const getLessonsForTeachers = timetableFactory.createHandlers( - describeRoute({ - ...filcExt('Lesson', teacherLessonBatchType), - description: 'Get lessons for multiple teachers in a single request.', - requestBody: { - content: { - 'application/json': await resolver( - teacherLessonsBatchRequestSchema - ).toOpenAPISchema(), - }, - required: true, - }, - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(teacherLessonsBatchResponseSchema), - }, - }, - description: 'Successful Response', - }, - }, - tags: ['Lesson'], - }), - zValidator('json', teacherLessonsBatchRequestSchema), - async (c) => { - const { teacherIds } = c.req.valid('json'); +export const getLessonsForTeachers = + base.timetable.lessons.getForTeachers.handler(async ({ input }) => { + const { teacherIds } = input; const normalizedTeacherIds = Array.from(new Set(teacherIds)); const existingTeachers = await db @@ -722,14 +529,14 @@ export const getLessonsForTeachers = timetableFactory.createHandlers( const existingTeacherIds = existingTeachers.map((t) => t.id); if (existingTeacherIds.length === 0) { - return ok(c, []); + return []; } const timetableId = await getActiveTimetableId(); // No active timetable: yield no lessons rather than every timetable. if (!timetableId) { - return ok(c, []); + return []; } const lessons = await db @@ -767,203 +574,146 @@ export const getLessonsForTeachers = timetableFactory.createHandlers( teacherId, })); - return ok(c, data); - } -); + return data; + }); -export const getSubstitutionCandidates = timetableFactory.createHandlers( - describeRoute({ - ...filcExt('Lesson', substitutionCandidatesType), - description: - 'Get available lessons and substitute teacher candidates for substitution editing in one request.', - requestBody: { - content: { - 'application/json': await resolver( - substitutionCandidatesRequestSchema - ).toOpenAPISchema(), - }, - required: true, - }, - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(substitutionCandidatesResponseSchema), - }, - }, - description: 'Successful Response', - }, - }, - tags: ['Lesson'], - }), - zValidator('json', substitutionCandidatesRequestSchema), - async (c) => { - const { date, missingTeacherId, selectedLessonIds, teacherIds } = - c.req.valid('json'); - const normalizedTeacherIds = Array.from(new Set(teacherIds)); +export const getSubstitutionCandidates = + base.timetable.lessons.getSubstitutionCandidates.handler( + async ({ input }) => { + const { date, missingTeacherId, selectedLessonIds, teacherIds } = input; + const normalizedTeacherIds = Array.from(new Set(teacherIds)); - const [missingTeacher] = await db - .select({ id: teacher.id }) - .from(teacher) - .where(eq(teacher.id, missingTeacherId)) - .limit(1); + const [missingTeacher] = await db + .select({ id: teacher.id }) + .from(teacher) + .where(eq(teacher.id, missingTeacherId)) + .limit(1); - if (!missingTeacher) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: 'Teacher not found', - }); - } + if (!missingTeacher) { + throw notFound('Teacher not found'); + } - const weekday = getWeekdayInBudapest(date); - const timetableId = await getTimetableIdForDate(date); + const weekday = getWeekdayInBudapest(date); + const timetableId = await getTimetableIdForDate(date); - if (!timetableId) { - return ok(c, { - availableLessons: [], - parallelLessons: [], - substituteCandidates: [], - }); - } + if (!timetableId) { + return { + availableLessons: [], + parallelLessons: [], + substituteCandidates: [], + }; + } - const missingTeacherLessons = await db - .select() - .from(lesson) - .where( - and( - arrayContains(lesson.teacherIds, [missingTeacherId]), - eq(lesson.timetableId, timetableId) - ) + const missingTeacherLessons = await db + .select() + .from(lesson) + .where( + and( + arrayContains(lesson.teacherIds, [missingTeacherId]), + eq(lesson.timetableId, timetableId) + ) + ); + + const enrichedMissingTeacherLessons = await enrichLessons( + missingTeacherLessons ); - const enrichedMissingTeacherLessons = await enrichLessons( - missingTeacherLessons - ); + const availableLessons = enrichedMissingTeacherLessons.filter( + (currentLesson) => + currentLesson.day + ? isMatchingWeekday( + weekday, + currentLesson.day.name, + currentLesson.day.short + ) + : false + ); - const availableLessons = enrichedMissingTeacherLessons.filter( - (currentLesson) => - currentLesson.day - ? isMatchingWeekday( - weekday, - currentLesson.day.name, - currentLesson.day.short - ) - : false - ); + const selectedLessonIdsSet = new Set(selectedLessonIds); + const selectedLessons = availableLessons.filter((currentLesson) => + selectedLessonIdsSet.has(currentLesson.id) + ); - const selectedLessonIdsSet = new Set(selectedLessonIds); - const selectedLessons = availableLessons.filter((currentLesson) => - selectedLessonIdsSet.has(currentLesson.id) - ); + const selectedPeriods = selectedLessons + .map((currentLesson) => currentLesson.period?.period) + .filter( + (currentPeriod): currentPeriod is number => + typeof currentPeriod === 'number' + ); + + if (selectedPeriods.length === 0) { + return { + availableLessons, + parallelLessons: [], + substituteCandidates: [], + }; + } - const selectedPeriods = selectedLessons - .map((currentLesson) => currentLesson.period?.period) - .filter( - (currentPeriod): currentPeriod is number => - typeof currentPeriod === 'number' + const parallelLessons = await getParallelLessons( + selectedLessons, + missingTeacherId, + timetableId ); - if (selectedPeriods.length === 0) { - return ok(c, { - availableLessons, - parallelLessons: [], - substituteCandidates: [], - }); - } + const candidateTeacherIds = normalizedTeacherIds.filter( + (teacherId) => teacherId !== missingTeacherId + ); - const parallelLessons = await getParallelLessons( - selectedLessons, - missingTeacherId, - timetableId - ); + if (candidateTeacherIds.length === 0) { + return { + availableLessons, + parallelLessons, + substituteCandidates: [], + }; + } - const candidateTeacherIds = normalizedTeacherIds.filter( - (teacherId) => teacherId !== missingTeacherId - ); + const candidateTeachers = await db + .select({ + firstName: teacher.firstName, + id: teacher.id, + lastName: teacher.lastName, + short: teacher.short, + }) + .from(teacher) + .where(inArray(teacher.id, candidateTeacherIds)); + + const candidateLessonsByTeacherId = await buildCandidateLessonsMap( + candidateTeacherIds, + weekday, + timetableId + ); - if (candidateTeacherIds.length === 0) { - return ok(c, { + const substituteCandidates = candidateTeachers + .map((currentTeacher) => { + const teacherLessons = + candidateLessonsByTeacherId.get(currentTeacher.id) ?? []; + const flags = computeCandidateFlags(teacherLessons, selectedPeriods); + if (flags.hasConflict) { + return null; + } + return { + hasH1: flags.hasH1, + hasH2: flags.hasH2, + teacher: currentTeacher, + }; + }) + .filter( + (candidate): candidate is NonNullable => + candidate !== null + ) + .sort(compareSubstituteCandidates); + + return { availableLessons, parallelLessons, - substituteCandidates: [], - }); + substituteCandidates, + }; } + ); - const candidateTeachers = await db - .select({ - firstName: teacher.firstName, - id: teacher.id, - lastName: teacher.lastName, - short: teacher.short, - }) - .from(teacher) - .where(inArray(teacher.id, candidateTeacherIds)); - - const candidateLessonsByTeacherId = await buildCandidateLessonsMap( - candidateTeacherIds, - weekday, - timetableId - ); - - const substituteCandidates = candidateTeachers - .map((currentTeacher) => { - const teacherLessons = - candidateLessonsByTeacherId.get(currentTeacher.id) ?? []; - const flags = computeCandidateFlags(teacherLessons, selectedPeriods); - if (flags.hasConflict) { - return null; - } - return { - hasH1: flags.hasH1, - hasH2: flags.hasH2, - teacher: currentTeacher, - }; - }) - .filter( - (candidate): candidate is NonNullable => - candidate !== null - ) - .sort(compareSubstituteCandidates); - - return ok(c, { - availableLessons, - parallelLessons, - substituteCandidates, - }); - } -); - -export const getLessonsForRoom = timetableFactory.createHandlers( - describeRoute({ - ...filcExt('Lesson', enrichedLessonType), - description: 'Get lessons for a given classroom from the database.', - parameters: [ - { - in: 'path', - name: 'classroomId', - required: true, - schema: { - description: 'The unique identifier for the classroom.', - type: 'string', - }, - }, - ], - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(responseSchema), - }, - }, - description: 'Successful Response', - }, - }, - tags: ['Lesson'], - }), - zValidator('param', getLessonsForRoomParamsSchema), - zValidator('query', getLessonsQuerySchema), - async (c) => { - const { classroomId } = c.req.valid('param'); - const { timetableId } = c.req.valid('query'); +export const getLessonsForRoom = base.timetable.lessons.getForRoom.handler( + async ({ input }) => { + const { classroomId, timetableId } = input; const [existingClassroom] = await db .select() @@ -972,16 +722,14 @@ export const getLessonsForRoom = timetableFactory.createHandlers( .limit(1); if (!existingClassroom) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: 'Classroom not found', - }); + throw notFound('Classroom not found'); } const effectiveTimetableId = timetableId ?? (await getActiveTimetableId()); // No active timetable: yield no lessons rather than every timetable. if (!effectiveTimetableId) { - return ok(c, []); + return []; } const whereClause = and( @@ -992,57 +740,18 @@ export const getLessonsForRoom = timetableFactory.createHandlers( const lessons = await db.select().from(lesson).where(whereClause); if (lessons.length === 0) { - return ok(c, []); + return []; } const enriched = await enrichLessons(lessons); - return ok(c, enriched); + return enriched; } ); -export const getLessonForId = timetableFactory.createHandlers( - describeRoute({ - ...filcExt( - 'Lesson', - '@unit EnrichedLesson @field(.classrooms, List) @field(.day, DayDefinition) @field(.period, Period) @field(.subject, Subject) @field(.teachers, List)' - ), - description: 'Get a lesson by its ID from the database.', - parameters: [ - { - in: 'path', - name: 'lessonId', - required: true, - schema: { - description: 'The unique identifier for the lesson.', - type: 'string', - }, - }, - ], - responses: { - 200: { - content: { - 'application/json': { - schema: resolver( - z.object({ - data: enrichedLessonSchema - .extend({ - substitutionCohortName: z.string().nullable(), - }) - .nullable(), - success: z.boolean(), - }) - ), - }, - }, - description: 'Successful Response', - }, - }, - tags: ['Lesson'], - }), - zValidator('param', getLessonForIdParamsSchema), - async (c) => { - const { lessonId } = c.req.valid('param'); +export const getLessonForId = base.timetable.lessons.getForId.handler( + async ({ input }) => { + const { lessonId } = input; const lessonRow = await db .select() @@ -1051,7 +760,7 @@ export const getLessonForId = timetableFactory.createHandlers( .limit(1); if (!lessonRow) { - return ok(c, null); + return null; } const substitutionCohortRow = await db @@ -1067,49 +776,26 @@ export const getLessonForId = timetableFactory.createHandlers( const [enriched] = await enrichLessons(lessonRow); - return ok(c, { + if (!enriched) { + return null; + } + + return { ...enriched, substitutionCohortName: substitutionCohortRow.length > 0 ? (substitutionCohortRow[0]?.name ?? null) : null, - }); + }; } ); -const getSubjectsResponseSchema = z.object({ - data: createSelectSchema(subject).array(), - success: z.boolean(), -}); - -export const getSubjects = timetableFactory.createHandlers( - describeRoute({ - ...filcExt('Subject', '@listof Subject', true), - description: 'Get all subjects from the database.', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(getSubjectsResponseSchema), - }, - }, - description: 'Successful Response', - }, - }, - tags: ['Subject'], - }), - async (c) => { - try { - const subjects = await db.select().from(subject); - return c.json>({ - data: subjects, - success: true, - }); - } catch (error) { - logger.error('Error while fetching subjects', { error }); - throw new HTTPException(StatusCodes.INTERNAL_SERVER_ERROR, { - message: 'Failed to fetch subjects', - }); - } +export const getSubjects = base.timetable.subjects.handler(async () => { + try { + const subjects = await db.select().from(subject); + return subjects; + } catch (error) { + logger.error('Error while fetching subjects', { error }); + throw new ORPCError('INTERNAL', { message: 'Failed to fetch subjects' }); } -); +}); diff --git a/apps/chronos/src/routes/timetable/moved-lesson.ts b/apps/chronos/src/routes/timetable/moved-lesson.ts index f7b55e6c..9f35a56f 100644 --- a/apps/chronos/src/routes/timetable/moved-lesson.ts +++ b/apps/chronos/src/routes/timetable/moved-lesson.ts @@ -1,14 +1,6 @@ -import { - cohortIdParamsSchema, - movedLessonIdParamsSchema, - updateSchema, -} from '@filcdev/api/domains/timetable/moved-lesson'; -import { zValidator } from '@hono/zod-validator'; +import { permissions } from '@filcdev/api/permissions'; +import { ORPCError } from '@orpc/server'; import { and, eq, gte, inArray, sql } from 'drizzle-orm'; -import { HTTPException } from 'hono/http-exception'; -import { describeRoute, resolver } from 'hono-openapi'; -import { StatusCodes } from 'http-status-codes'; -import z from 'zod'; import { db } from '#database'; import { classroom, @@ -19,21 +11,19 @@ import { movedLessonLessonMTM, period, } from '#database/schema/timetable'; -import { authRouter } from '#middleware/auth'; -import { created, ok } from '#utils/http'; +import { requireAuthorization } from '#middleware/auth'; +import { base } from '#orpc'; +import { env } from '#utils/environment'; +import { badRequest, notFound } from '#utils/http'; import { cancelPendingNotification, dispatchPendingNotification, } from '#utils/notifications/engine'; -import { filcExt } from '#utils/openapi'; import { getActiveTimetableId } from '#utils/timetable/active'; import { type EnrichedLesson, - enrichedLessonSchema, enrichLessons, } from '#utils/timetable/enrich-lessons'; -import { createInsertSchema, createSelectSchema } from '#utils/zod'; -import { timetableFactory } from './_factory'; const ensurePeriodExists = async (periodId: string) => { const [existingPeriod] = await db @@ -42,9 +32,7 @@ const ensurePeriodExists = async (periodId: string) => { .where(eq(period.id, periodId)); if (!existingPeriod) { - throw new HTTPException(StatusCodes.BAD_REQUEST, { - message: 'Invalid starting period provided', - }); + throw badRequest('Invalid starting period provided'); } }; @@ -55,9 +43,7 @@ const ensureDayDefinitionExists = async (dayId: string) => { .where(eq(dayDefinition.id, dayId)); if (!existingDay) { - throw new HTTPException(StatusCodes.BAD_REQUEST, { - message: 'Invalid starting day provided', - }); + throw badRequest('Invalid starting day provided'); } }; @@ -68,9 +54,7 @@ const ensureClassroomExists = async (classroomId: string) => { .where(eq(classroom.id, classroomId)); if (!existingRoom) { - throw new HTTPException(StatusCodes.BAD_REQUEST, { - message: 'Invalid classroom provided', - }); + throw badRequest('Invalid classroom provided'); } }; @@ -97,9 +81,9 @@ const ensureLessonsExist = async ( ); if (missingLessonIds.length > 0) { - throw new HTTPException(StatusCodes.BAD_REQUEST, { - message: `Invalid lesson ids provided: ${missingLessonIds.join(', ')}`, - }); + throw badRequest( + `Invalid lesson ids provided: ${missingLessonIds.join(', ')}` + ); } return lessonRecords; @@ -114,9 +98,7 @@ const normalizeOptionalString = ( } if (typeof value !== 'string') { - throw new HTTPException(StatusCodes.BAD_REQUEST, { - message: `${label} must be a string`, - }); + throw badRequest(`${label} must be a string`); } return value; @@ -131,16 +113,12 @@ const normalizeOptionalStringArray = ( } if (!Array.isArray(value)) { - throw new HTTPException(StatusCodes.BAD_REQUEST, { - message: `${label} must be an array`, - }); + throw badRequest(`${label} must be an array`); } return value.map((entry) => { if (typeof entry !== 'string') { - throw new HTTPException(StatusCodes.BAD_REQUEST, { - message: `${label} must contain only strings`, - }); + throw badRequest(`${label} must contain only strings`); } return entry; @@ -189,28 +167,13 @@ const validateMovedLessonReferences = async (options: { ({ periodId }) => periodId !== normalizedStartingPeriod ) ) { - throw new HTTPException(StatusCodes.BAD_REQUEST, { - message: 'Provided lessons do not match the starting period', - }); + throw badRequest('Provided lessons do not match the starting period'); } } }; // Shared by every moved-lesson list endpoint: rows carry the target joins and // their linked lessons already enriched. -const movedLessonsResponseSchema = z.object({ - data: z.array( - z.object({ - classroom: createSelectSchema(classroom).nullable(), - dayDefinition: createSelectSchema(dayDefinition).nullable(), - lessons: z.array(enrichedLessonSchema), - movedLesson: createSelectSchema(movedLesson), - period: createSelectSchema(period).nullable(), - }) - ), - success: z.boolean(), -}); - // Row shape returned by each moved-lesson query before lesson enrichment. type MovedLessonRow = { classroom: typeof classroom.$inferSelect | null; @@ -240,26 +203,8 @@ async function attachEnrichedLessons(rows: MovedLessonRow[]) { })); } -const movedLessonWithRelationsType = - '@listof MovedLessonWithRelations @field(.movedLesson, MovedLesson) @field(.classroom, Classroom) @field(.dayDefinition, DayDefinition) @field(.period, Period) @field(.lessons, List)'; - -export const getAllMovedLessons = timetableFactory.createHandlers( - describeRoute({ - ...filcExt('MovedLesson', movedLessonWithRelationsType), - description: 'Get all moved lessons.', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(movedLessonsResponseSchema), - }, - }, - description: 'Successful Response', - }, - }, - tags: ['Moved Lesson'], - }), - async (c) => { +export const getAllMovedLessons = base.timetable.movedLessons.list.handler( + async () => { const movedLessons = await db .select({ classroom, @@ -281,27 +226,12 @@ export const getAllMovedLessons = timetableFactory.createHandlers( ) .groupBy(movedLesson.id, period.id, dayDefinition.id, classroom.id); - return ok(c, await attachEnrichedLessons(movedLessons)); + return await attachEnrichedLessons(movedLessons); } ); -export const getRelevantMovedLessons = timetableFactory.createHandlers( - describeRoute({ - ...filcExt('MovedLesson', movedLessonWithRelationsType), - description: 'Get relevant moved lessons for the active timetable.', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(movedLessonsResponseSchema), - }, - }, - description: 'Successful Response', - }, - }, - tags: ['Moved Lesson'], - }), - async (c) => { +export const getRelevantMovedLessons = + base.timetable.movedLessons.relevant.handler(async () => { const today = new Date(); today.setHours(0, 0, 0, 0); @@ -327,41 +257,12 @@ export const getRelevantMovedLessons = timetableFactory.createHandlers( .where(gte(movedLesson.date, today)) .groupBy(movedLesson.id, period.id, dayDefinition.id, classroom.id); - return ok(c, await attachEnrichedLessons(movedLessons)); - } -); + return await attachEnrichedLessons(movedLessons); + }); -export const getMovedLessonsForCohort = timetableFactory.createHandlers( - describeRoute({ - ...filcExt('MovedLesson', movedLessonWithRelationsType), - description: 'Get all moved lessons for a cohort.', - parameters: [ - { - in: 'path', - name: 'cohortId', - required: true, - schema: { - description: - 'The unique identifier for the cohort to get the moved lessons for.', - type: 'string', - }, - }, - ], - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(movedLessonsResponseSchema), - }, - }, - description: 'Successful Response', - }, - }, - tags: ['Moved Lesson'], - }), - zValidator('param', cohortIdParamsSchema), - async (c) => { - const { cohortId } = c.req.valid('param'); +export const getMovedLessonsForCohort = + base.timetable.movedLessons.forCohort.handler(async ({ input }) => { + const { cohortId } = input; const movedLessons = await db .select({ @@ -387,41 +288,12 @@ export const getMovedLessonsForCohort = timetableFactory.createHandlers( .where(eq(lessonCohortMTM.cohortId, cohortId)) .groupBy(movedLesson.id, period.id, dayDefinition.id, classroom.id); - return ok(c, await attachEnrichedLessons(movedLessons)); - } -); + return await attachEnrichedLessons(movedLessons); + }); -export const getRelevantMovedLessonsForCohort = timetableFactory.createHandlers( - describeRoute({ - ...filcExt('MovedLesson', movedLessonWithRelationsType), - description: 'Get all relevant moved lessons for a given cohort.', - parameters: [ - { - in: 'path', - name: 'cohortId', - required: true, - schema: { - description: - 'The unique identifier for the cohort to get the relevant moved lessons for.', - type: 'string', - }, - }, - ], - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(movedLessonsResponseSchema), - }, - }, - description: 'Successful Response', - }, - }, - tags: ['Moved Lesson'], - }), - zValidator('param', cohortIdParamsSchema), - async (c) => { - const { cohortId } = c.req.valid('param'); +export const getRelevantMovedLessonsForCohort = + base.timetable.movedLessons.relevantForCohort.handler(async ({ input }) => { + const { cohortId } = input; const today = new Date(); today.setHours(0, 0, 0, 0); @@ -455,55 +327,18 @@ export const getRelevantMovedLessonsForCohort = timetableFactory.createHandlers( ) .groupBy(movedLesson.id, period.id, dayDefinition.id, classroom.id); - return ok(c, await attachEnrichedLessons(movedLessons)); - } -); - -const createSchema = createInsertSchema(movedLesson) - .omit({ id: true }) - .extend({ - date: z.coerce.date(), - lessonIds: z.uuid().array().min(1), + return await attachEnrichedLessons(movedLessons); }); -const createResponseSchema = z.object({ - data: createSelectSchema(movedLesson), - success: z.boolean(), -}); - -export const createMovedLesson = timetableFactory.createHandlers( - describeRoute({ - ...filcExt('MovedLesson', '@unit MovedLesson', true), - description: 'Create a moved lesson.', - requestBody: { - content: { - 'application/json': await resolver(createSchema).toOpenAPISchema(), - }, - description: 'The data for the moved lesson.', - }, - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(createResponseSchema), - }, - }, - description: 'Successful Response', - }, - }, - tags: ['Moved Lesson'], - }), - ...authRouter('movedLesson:create'), - zValidator('json', createSchema), - async (c) => { - const body = c.req.valid('json'); +export const createMovedLesson = base.timetable.movedLessons.create + .use(requireAuthorization(permissions.movedLessonCreate)) + .handler(async ({ input }) => { + const body = input; const { startingPeriod, startingDay, room, date, lessonIds, comment } = body; if (!date) { - throw new HTTPException(StatusCodes.BAD_REQUEST, { - message: 'Date is required', - }); + throw badRequest('Date is required'); } await validateMovedLessonReferences({ @@ -549,50 +384,25 @@ export const createMovedLesson = timetableFactory.createHandlers( }); } - return created(c, newMovedLesson); - } -); + if (!newMovedLesson) { + throw new ORPCError('INTERNAL', { + cause: + env.mode === 'development' + ? 'No moved lesson returned from insert query' + : undefined, + message: 'Failed to create moved lesson.', + }); + } + + return newMovedLesson; + }); -export const updateMovedLesson = timetableFactory.createHandlers( - describeRoute({ - ...filcExt('MovedLesson', '@unit MovedLesson', true), - description: 'Update a moved lesson.', - parameters: [ - { - in: 'path', - name: 'id', - required: true, - schema: { - description: 'The unique identifier for the moved lesson to update.', - type: 'string', - }, - }, - ], - requestBody: { - content: { - 'application/json': await resolver(updateSchema).toOpenAPISchema(), - }, - description: 'The data for updating the moved lesson.', - }, - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(createResponseSchema), - }, - }, - description: 'Successful Response', - }, - }, - tags: ['Moved Lesson'], - }), - ...authRouter('movedLesson:update'), - zValidator('param', movedLessonIdParamsSchema), - zValidator('json', updateSchema), - async (c) => { - const { id } = c.req.valid('param'); +export const updateMovedLesson = base.timetable.movedLessons.update + .use(requireAuthorization(permissions.movedLessonUpdate)) + .handler(async ({ input }) => { + const { id } = input; const { startingPeriod, startingDay, room, date, lessonIds, comment } = - c.req.valid('json'); + input; await validateMovedLessonReferences({ lessonIds, @@ -617,9 +427,7 @@ export const updateMovedLesson = timetableFactory.createHandlers( .returning(); if (!updatedMovedLesson) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: 'Moved lesson not found', - }); + throw notFound('Moved lesson not found'); } if (lessonIds !== undefined && Array.isArray(lessonIds)) { @@ -645,41 +453,13 @@ export const updateMovedLesson = timetableFactory.createHandlers( startingPeriod, }); - return ok(c, updatedMovedLesson); - } -); + return updatedMovedLesson; + }); -export const deleteMovedLesson = timetableFactory.createHandlers( - describeRoute({ - ...filcExt('MovedLesson', '@nodata', true), - description: 'Delete a moved lesson', - parameters: [ - { - in: 'path', - name: 'id', - required: true, - schema: { - description: 'The unique identifier for the moved lesson to delete.', - type: 'string', - }, - }, - ], - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(createResponseSchema), - }, - }, - description: 'Successful Response', - }, - }, - tags: ['Moved Lesson'], - }), - ...authRouter('movedLesson:delete'), - zValidator('param', movedLessonIdParamsSchema), - async (c) => { - const { id } = c.req.valid('param'); +export const deleteMovedLesson = base.timetable.movedLessons.delete + .use(requireAuthorization(permissions.movedLessonDelete)) + .handler(async ({ input }) => { + const { id } = input; const [deletedMovedLesson] = await db .delete(movedLesson) @@ -687,13 +467,10 @@ export const deleteMovedLesson = timetableFactory.createHandlers( .returning(); if (!deletedMovedLesson) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: 'Moved lesson not found', - }); + throw notFound('Moved lesson not found'); } cancelPendingNotification(id, 'moved_lesson'); - return ok(c, deletedMovedLesson); - } -); + return { id: deletedMovedLesson.id }; + }); diff --git a/apps/chronos/src/routes/timetable/period.ts b/apps/chronos/src/routes/timetable/period.ts index 8e20751d..2afee48d 100644 --- a/apps/chronos/src/routes/timetable/period.ts +++ b/apps/chronos/src/routes/timetable/period.ts @@ -1,49 +1,11 @@ -import { - getPeriodsQuerySchema, - getPeriodsResponseSchema, -} from '@filcdev/api/domains/timetable/period'; -import { zValidator } from '@hono/zod-validator'; import { asc, eq, inArray } from 'drizzle-orm'; -import { describeRoute, resolver } from 'hono-openapi'; import { db } from '#database'; import { lesson, period } from '#database/schema/timetable'; -import { ok } from '#utils/http'; -import { filcExt } from '#utils/openapi'; -import { timetableFactory } from './_factory'; +import { base } from '#orpc'; -export const getPeriodsForTimetable = timetableFactory.createHandlers( - describeRoute({ - ...filcExt('Period', '@listof Period'), - description: - 'Get all period definitions used in a given timetable, sorted by period number.', - parameters: [ - { - in: 'query', - name: 'timetableId', - required: false, - schema: { - description: - 'Optional timetable id to scope periods to those used in that timetable.', - format: 'uuid', - type: 'string', - }, - }, - ], - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(getPeriodsResponseSchema), - }, - }, - description: 'Successful Response', - }, - }, - tags: ['Period'], - }), - zValidator('query', getPeriodsQuerySchema), - async (c) => { - const { timetableId } = c.req.valid('query'); +export const getPeriodsForTimetable = base.timetable.periods.getAll.handler( + async ({ input }) => { + const { timetableId } = input; let periods: (typeof period.$inferSelect)[]; @@ -57,7 +19,7 @@ export const getPeriodsForTimetable = timetableFactory.createHandlers( const ids = usedPeriodIds.map((r) => r.periodId); if (ids.length === 0) { - return ok(c, []); + return []; } periods = await db @@ -76,6 +38,6 @@ export const getPeriodsForTimetable = timetableFactory.createHandlers( startTime: String(p.startTime), })); - return ok(c, data); + return data; } ); diff --git a/apps/chronos/src/routes/timetable/room.ts b/apps/chronos/src/routes/timetable/room.ts index a17c3c56..80a0266f 100644 --- a/apps/chronos/src/routes/timetable/room.ts +++ b/apps/chronos/src/routes/timetable/room.ts @@ -1,8 +1,4 @@ -import { getAvailableClassroomsQuerySchema } from '@filcdev/api/domains/timetable/room'; -import { zValidator } from '@hono/zod-validator'; import { and, eq, notInArray, sql } from 'drizzle-orm'; -import { describeRoute, resolver } from 'hono-openapi'; -import z from 'zod'; import { db } from '#database'; import { classroom, @@ -10,108 +6,18 @@ import { movedLesson, movedLessonLessonMTM, } from '#database/schema/timetable'; -import { ok } from '#utils/http'; -import { filcExt } from '#utils/openapi'; -import { createSelectSchema } from '#utils/zod'; -import { timetableFactory } from './_factory'; +import { base } from '#orpc'; -const getClassroomsResponseSchema = z.object({ - data: createSelectSchema(classroom, { - building_id: z.string().nullable(), - }).array(), - success: z.boolean(), -}); - -const getAvailableClassroomsResponseSchema = z.object({ - data: createSelectSchema(classroom, { - building_id: z.string().nullable(), - }).array(), - success: z.boolean(), -}); - -export const getClassrooms = timetableFactory.createHandlers( - describeRoute({ - ...filcExt('Classroom', '@listof Classroom'), - description: 'Get all classrooms from the database.', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(getClassroomsResponseSchema), - }, - }, - description: 'Successful Response', - }, - }, - tags: ['Classroom'], - }), - async (c) => { +export const getClassrooms = base.timetable.classrooms.getAll.handler( + async () => { const classrooms = await db.select().from(classroom); - return ok(c, classrooms); + return classrooms; } ); -export const getAvailableClassrooms = timetableFactory.createHandlers( - describeRoute({ - ...filcExt('Classroom', '@listof Classroom'), - description: - 'Get classrooms that are free for a given date, day and period.', - parameters: [ - { - in: 'query', - name: 'date', - required: true, - schema: { - description: 'The exact date to check for available classrooms.', - format: 'date', - type: 'string', - }, - }, - { - in: 'query', - name: 'startingDay', - required: true, - schema: { - description: 'The day definition id for the target slot.', - type: 'string', - }, - }, - { - in: 'query', - name: 'startingPeriod', - required: true, - schema: { - description: 'The period id for the target slot.', - type: 'string', - }, - }, - { - in: 'query', - name: 'timetableId', - required: false, - schema: { - description: - 'Optional timetable id to limit the search to a specific timetable.', - type: 'string', - }, - }, - ], - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(getAvailableClassroomsResponseSchema), - }, - }, - description: 'Successful Response', - }, - }, - tags: ['Classroom'], - }), - zValidator('query', getAvailableClassroomsQuerySchema), - async (c) => { - const { date, startingDay, startingPeriod, timetableId } = - c.req.valid('query'); +export const getAvailableClassrooms = + base.timetable.classrooms.getAvailable.handler(async ({ input }) => { + const { date, startingDay, startingPeriod, timetableId } = input; // Get available classrooms in one query const availableClassrooms = await db @@ -168,6 +74,5 @@ export const getAvailableClassrooms = timetableFactory.createHandlers( ) ); - return ok(c, availableClassrooms); - } -); + return availableClassrooms; + }); diff --git a/apps/chronos/src/routes/timetable/substitution.ts b/apps/chronos/src/routes/timetable/substitution.ts index 3739060b..e8009b37 100644 --- a/apps/chronos/src/routes/timetable/substitution.ts +++ b/apps/chronos/src/routes/timetable/substitution.ts @@ -1,15 +1,6 @@ -import { - cohortIdParamsSchema, - manualCreateSchema, - substitutionIdParamsSchema, -} from '@filcdev/api/domains/timetable/substitution'; -import { zValidator } from '@hono/zod-validator'; +import { permissions } from '@filcdev/api/permissions'; +import { ORPCError } from '@orpc/server'; import { and, asc, eq, gte, inArray, isNull, ne, sql } from 'drizzle-orm'; -import { HTTPException } from 'hono/http-exception'; -import { describeRoute, resolver } from 'hono-openapi'; -import { StatusCodes } from 'http-status-codes'; -import z from 'zod'; -import type { SuccessResponse } from '#_types/globals'; import { db } from '#database'; import { cohort, @@ -24,62 +15,24 @@ import { termDefinition, weekDefinition, } from '#database/schema/timetable'; -import { authRouter } from '#middleware/auth'; +import { requireAuthorization } from '#middleware/auth'; +import { base } from '#orpc'; import { env } from '#utils/environment'; -import { ok } from '#utils/http'; +import { badRequest, conflict, notFound } from '#utils/http'; import { cancelPendingNotification, dispatchPendingNotification, } from '#utils/notifications/engine'; import { loadSubstitutionTeacherPayload } from '#utils/notifications/substitution-teacher'; -import { filcExt } from '#utils/openapi'; import { getActiveTimetableId } from '#utils/timetable/active'; import { - enrichedLessonSchema, + type EnrichedLesson, enrichLessons, } from '#utils/timetable/enrich-lessons'; import { getWeekdayInBudapest, isMatchingWeekday, } from '#utils/timetable/weekday'; -import { - createInsertSchema, - createSelectSchema, - createUpdateSchema, -} from '#utils/zod'; -import { timetableFactory } from './_factory'; - -const substitutionSchema = createSelectSchema(substitution); - -const allSubstitutionsResponseSchema = z.object({ - data: z.array( - z.object({ - lessons: z.array(enrichedLessonSchema), - substitution: substitutionSchema, - teacher: createSelectSchema(teacher).nullable(), - }) - ), - success: z.boolean(), -}); - -const relevantSubstitutionItemSchema = z.object({ - lessons: z.array(z.string()), - substitution: substitutionSchema, - teacher: createSelectSchema(teacher).nullable(), -}); - -const relevantSubstitutionsResponseSchema = z.object({ - data: z.array(relevantSubstitutionItemSchema), - success: z.boolean(), -}); - -const cohortSubstitutionsResponseSchema = z.object({ - data: z.object({ - cohortId: z.string(), - substitutions: z.array(relevantSubstitutionItemSchema), - }), - success: z.boolean(), -}); // Type for both database and transaction instances used by helpers type TxOrDb = typeof db | Parameters[0]>[0]; @@ -163,10 +116,9 @@ function assertPeriodsCompatible( continue; } if (!areLessonsCompatible(a, b, lessonCohorts)) { - throw new HTTPException(StatusCodes.CONFLICT, { - message: - 'Teacher already has a substitution in the same period on this date', - }); + throw conflict( + 'Teacher already has a substitution in the same period on this date' + ); } } } @@ -308,10 +260,9 @@ async function checkTeacherSubstitutionConflict( continue; } - throw new HTTPException(StatusCodes.CONFLICT, { - message: - 'Teacher already has a substitution in the same period on this date', - }); + throw conflict( + 'Teacher already has a substitution in the same period on this date' + ); } } @@ -350,26 +301,8 @@ async function validateUpdateTeacherConflict( ); } -const substitutionWithRelationsType = - '@listof SubstitutionWithRelations @field(.substitution, Substitution) @field(.teacher, Teacher) @field(.lessons, List)'; - -export const getAllSubstitutions = timetableFactory.createHandlers( - describeRoute({ - ...filcExt('Substitution', substitutionWithRelationsType, true), - description: 'Get all substitutions from the database.', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(allSubstitutionsResponseSchema), - }, - }, - description: 'Successful Response', - }, - }, - tags: ['Substitution'], - }), - async (c) => { +export const getAllSubstitutions = base.timetable.substitutions.list.handler( + async () => { // First get all substitutions with their lesson IDs const substitutions = await db .select({ @@ -393,10 +326,13 @@ export const getAllSubstitutions = timetableFactory.createHandlers( // END // `.as('teacher'), teacher: { + email: teacher.email, firstName: teacher.firstName, + gender: teacher.gender, id: teacher.id, lastName: teacher.lastName, short: teacher.short, + userId: teacher.userId, }, }) .from(substitution) @@ -408,6 +344,7 @@ export const getAllSubstitutions = timetableFactory.createHandlers( .groupBy( substitution.id, teacher.id, + teacher.email, teacher.firstName, teacher.lastName, teacher.short, @@ -428,7 +365,9 @@ export const getAllSubstitutions = timetableFactory.createHandlers( // Map lessons back to substitutions const result = substitutions.map((s) => ({ - lessons: s.lessonIds.map((id) => lessonMap.get(id)).filter(Boolean), + lessons: s.lessonIds + .map((id) => lessonMap.get(id)) + .filter((l): l is EnrichedLesson => l !== undefined), substitution: s.substitution, teacher: s.teacher, })); @@ -452,27 +391,12 @@ export const getAllSubstitutions = timetableFactory.createHandlers( return minPeriod(a) - minPeriod(b); }); - return ok(c, result); + return result; } ); -export const getRelevantSubstitutions = timetableFactory.createHandlers( - describeRoute({ - ...filcExt('Substitution', substitutionWithRelationsType, true), - description: 'Get relevant substitutions from the database.', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(relevantSubstitutionsResponseSchema), - }, - }, - description: 'Successful Response', - }, - }, - tags: ['Substitution'], - }), - async (c) => { +export const getRelevantSubstitutions = + base.timetable.substitutions.relevant.handler(async () => { const today = new Date(); today.setHours(0, 0, 0, 0); @@ -497,125 +421,56 @@ export const getRelevantSubstitutions = timetableFactory.createHandlers( .groupBy(substitution.id, teacher.id) .orderBy(asc(substitution.date), asc(sql`MIN(${period.period})`)); - return ok(c, substitutions); - } -); + return substitutions; + }); export const getRelevantSubstitutionsForCohort = - timetableFactory.createHandlers( - describeRoute({ - ...filcExt( - 'Substitution', - '@unit SubstitutionsByCohort @field(.substitutions, List)', - true - ), - description: - 'Get relevant substitutions for a given cohort from the database.', - parameters: [ - { - in: 'path', - name: 'cohortId', - required: true, - schema: { - description: 'The unique identifier for the cohort.', - type: 'string', - }, - }, - ], - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(cohortSubstitutionsResponseSchema), - }, - }, - description: 'Successful Response', - }, - }, - tags: ['Substitution'], - }), - zValidator('param', cohortIdParamsSchema), - async (c) => { - const { cohortId } = c.req.valid('param'); - - const today = new Date(); - today.setHours(0, 0, 0, 0); - - const substitutions = await db - .select({ - lessons: sql`COALESCE( + base.timetable.substitutions.relevantForCohort.handler(async ({ input }) => { + const { cohortId } = input; + + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const substitutions = await db + .select({ + lessons: sql`COALESCE( ARRAY_AGG(${substitutionLessonMTM.lessonId}) FILTER (WHERE ${substitutionLessonMTM.lessonId} IS NOT NULL), ARRAY[]::text[] )`.as('lessons'), - substitution, - teacher, - }) - .from(substitution) - .leftJoin(teacher, eq(substitution.substituter, teacher.id)) - .leftJoin( - substitutionLessonMTM, - eq(substitution.id, substitutionLessonMTM.substitutionId) - ) - .leftJoin(lesson, eq(substitutionLessonMTM.lessonId, lesson.id)) - .leftJoin(period, eq(lesson.periodId, period.id)) - .leftJoin(lessonCohortMTM, eq(lesson.id, lessonCohortMTM.lessonId)) - .leftJoin(cohort, eq(lessonCohortMTM.cohortId, cohort.id)) - .where(and(gte(substitution.date, today), eq(cohort.id, cohortId))) - .groupBy(substitution.id, teacher.id) - .orderBy(asc(substitution.date), asc(sql`MIN(${period.period})`)); - - return ok(c, { - cohortId, - substitutions, - }); - } - ); + substitution, + teacher, + }) + .from(substitution) + .leftJoin(teacher, eq(substitution.substituter, teacher.id)) + .leftJoin( + substitutionLessonMTM, + eq(substitution.id, substitutionLessonMTM.substitutionId) + ) + .leftJoin(lesson, eq(substitutionLessonMTM.lessonId, lesson.id)) + .leftJoin(period, eq(lesson.periodId, period.id)) + .leftJoin(lessonCohortMTM, eq(lesson.id, lessonCohortMTM.lessonId)) + .leftJoin(cohort, eq(lessonCohortMTM.cohortId, cohort.id)) + .where(and(gte(substitution.date, today), eq(cohort.id, cohortId))) + .groupBy(substitution.id, teacher.id) + .orderBy(asc(substitution.date), asc(sql`MIN(${period.period})`)); -const createSchema = createInsertSchema(substitution) - .omit({ id: true }) - .extend({ - date: z.coerce.date(), - lessonIds: z.string().array().min(1), + return { + cohortId, + substitutions, + }; }); -const createResponseSchema = z.object({ - data: substitutionSchema, - success: z.boolean(), -}); - -export const createSubstitution = timetableFactory.createHandlers( - describeRoute({ - ...filcExt('Substitution', '@unit Substitution', true), - description: 'Create a new substitution', - requestBody: { - content: { - 'application/json': await resolver(createSchema).toOpenAPISchema(), - }, - description: 'The data for the new substitution.', - }, - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(createResponseSchema), - }, - }, - description: 'Successful Response', - }, - }, - tags: ['Substitution'], - }), - ...authRouter('substitution:create'), - zValidator('json', createSchema), - async (c) => { - const { lessonIds, date, substituter, comment } = c.req.valid('json'); +export const createSubstitution = base.timetable.substitutions.create + .use(requireAuthorization(permissions.substitutionCreate)) + .handler(async ({ input }) => { + const { lessonIds, date, substituter, comment } = input; const lessonCount = await db.$count(lesson, inArray(lesson.id, lessonIds)); if (lessonCount !== lessonIds.length) { - throw new HTTPException(StatusCodes.BAD_REQUEST, { - message: `attempted to substitute non-existent lesson(s), wanted: ${lessonIds.length}, got: ${lessonCount}`, - }); + throw badRequest( + `attempted to substitute non-existent lesson(s), wanted: ${lessonIds.length}, got: ${lessonCount}` + ); } const result = await db.transaction( @@ -638,7 +493,7 @@ export const createSubstitution = timetableFactory.createHandlers( .returning(); if (!insertedSubstitution) { - throw new HTTPException(StatusCodes.INTERNAL_SERVER_ERROR, { + throw new ORPCError('INTERNAL', { cause: env.mode === 'development' ? 'No substitution returned from insert query' @@ -676,9 +531,8 @@ export const createSubstitution = timetableFactory.createHandlers( }) ); - return ok(c, result); - } -); + return result; + }); // Manual substitution creation. // @@ -688,11 +542,6 @@ export const createSubstitution = timetableFactory.createHandlers( // cohort. We then find-or-create a lesson that matches that combination inside // the active timetable and link the substitution to it. -const manualCreateResponseSchema = z.object({ - data: substitutionSchema, - success: z.boolean(), -}); - // Find an existing lesson that matches the manual substitution parameters, or // create one if none exists yet. Returns the lesson id. async function findOrCreateManualLesson( @@ -785,12 +634,11 @@ async function lockAndValidateTeachers( .for('update'); if (!lockedTeacher) { - throw new HTTPException(StatusCodes.BAD_REQUEST, { - message: - id === teacherId - ? 'Invalid teacher provided' - : 'Invalid substituter provided', - }); + throw badRequest( + id === teacherId + ? 'Invalid teacher provided' + : 'Invalid substituter provided' + ); } } } @@ -813,33 +661,9 @@ async function getDayDefinitionIdForDate(date: Date): Promise { return match?.id ?? null; } -export const createManualSubstitution = timetableFactory.createHandlers( - describeRoute({ - ...filcExt('Substitution', '@unit Substitution', true), - description: - 'Create a substitution manually by specifying the teacher, lesson time, subject and cohort directly.', - requestBody: { - content: { - 'application/json': - await resolver(manualCreateSchema).toOpenAPISchema(), - }, - description: 'The data for the manually created substitution.', - }, - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(manualCreateResponseSchema), - }, - }, - description: 'Successful Response', - }, - }, - tags: ['Substitution'], - }), - ...authRouter('substitution:create'), - zValidator('json', manualCreateSchema), - async (c) => { +export const createManualSubstitution = base.timetable.substitutions.manual + .use(requireAuthorization(permissions.substitutionCreate)) + .handler(async ({ input }) => { const { cohortId, comment, @@ -848,7 +672,7 @@ export const createManualSubstitution = timetableFactory.createHandlers( subjectId, substituter, teacherId, - } = c.req.valid('json'); + } = input; // Validate that all referenced entities exist. const [[refTeacher], [refPeriod], [refCohort]] = await Promise.all([ @@ -870,19 +694,13 @@ export const createManualSubstitution = timetableFactory.createHandlers( ]); if (!refTeacher) { - throw new HTTPException(StatusCodes.BAD_REQUEST, { - message: 'Invalid teacher provided', - }); + throw badRequest('Invalid teacher provided'); } if (!refPeriod) { - throw new HTTPException(StatusCodes.BAD_REQUEST, { - message: 'Invalid period provided', - }); + throw badRequest('Invalid period provided'); } if (!refCohort) { - throw new HTTPException(StatusCodes.BAD_REQUEST, { - message: 'Invalid cohort provided', - }); + throw badRequest('Invalid cohort provided'); } if (subjectId !== null) { const [refSubject] = await db @@ -891,9 +709,7 @@ export const createManualSubstitution = timetableFactory.createHandlers( .where(eq(subject.id, subjectId)) .limit(1); if (!refSubject) { - throw new HTTPException(StatusCodes.BAD_REQUEST, { - message: 'Invalid subject provided', - }); + throw badRequest('Invalid subject provided'); } } @@ -904,24 +720,20 @@ export const createManualSubstitution = timetableFactory.createHandlers( .where(eq(teacher.id, substituter)) .limit(1); if (!refSubstituter) { - throw new HTTPException(StatusCodes.BAD_REQUEST, { - message: 'Invalid substituter provided', - }); + throw badRequest('Invalid substituter provided'); } } const timetableId = await getActiveTimetableId(); if (!timetableId) { - throw new HTTPException(StatusCodes.INTERNAL_SERVER_ERROR, { + throw new ORPCError('INTERNAL', { message: 'No active timetable found', }); } const dayDefinitionId = await getDayDefinitionIdForDate(date); if (!dayDefinitionId) { - throw new HTTPException(StatusCodes.BAD_REQUEST, { - message: 'No day definition found for the given date', - }); + throw badRequest('No day definition found for the given date'); } const [[weekDef], [termDef]] = await Promise.all([ @@ -930,7 +742,7 @@ export const createManualSubstitution = timetableFactory.createHandlers( ]); if (!weekDef) { - throw new HTTPException(StatusCodes.INTERNAL_SERVER_ERROR, { + throw new ORPCError('INTERNAL', { message: 'No week definition found', }); } @@ -968,7 +780,7 @@ export const createManualSubstitution = timetableFactory.createHandlers( .returning(); if (!insertedSubstitution) { - throw new HTTPException(StatusCodes.INTERNAL_SERVER_ERROR, { + throw new ORPCError('INTERNAL', { cause: env.mode === 'development' ? 'No substitution returned from insert query' @@ -1003,59 +815,13 @@ export const createManualSubstitution = timetableFactory.createHandlers( }) ); - return c.json>({ - data: result, - success: true, - }); - } -); - -const updateSchema = createUpdateSchema(substitution) - .omit({ id: true }) - .extend({ - date: z.coerce.date().optional(), - lessonIds: z.string().array().nullable(), + return result; }); -export const updateSubstitution = timetableFactory.createHandlers( - describeRoute({ - ...filcExt('Substitution', '@unit Substitution', true), - description: 'Update a substitution', - parameters: [ - { - in: 'path', - name: 'id', - required: true, - schema: { - description: 'The unique identifier for the substitution to update.', - type: 'string', - }, - }, - ], - requestBody: { - content: { - 'application/json': await resolver(updateSchema).toOpenAPISchema(), - }, - description: 'The data for updating the substitution.', - }, - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(createResponseSchema), - }, - }, - description: 'Successful Response', - }, - }, - tags: ['Substitution'], - }), - ...authRouter('substitution:update'), - zValidator('param', substitutionIdParamsSchema), - zValidator('json', updateSchema), - async (c) => { - const { id } = c.req.valid('param'); - const body = c.req.valid('json'); +export const updateSubstitution = base.timetable.substitutions.update + .use(requireAuthorization(permissions.substitutionUpdate)) + .handler(async ({ input }) => { + const { id, ...body } = input; const [existing] = await db .select() @@ -1064,9 +830,7 @@ export const updateSubstitution = timetableFactory.createHandlers( .limit(1); if (!existing) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: 'Substitution not found', - }); + throw notFound('Substitution not found'); } if (body.lessonIds?.length) { @@ -1075,9 +839,9 @@ export const updateSubstitution = timetableFactory.createHandlers( inArray(lesson.id, body.lessonIds) ); if (lessonCount !== body.lessonIds.length) { - throw new HTTPException(StatusCodes.BAD_REQUEST, { - message: `Some lessons don't exist, wanted: ${body.lessonIds.length}, found: ${lessonCount}`, - }); + throw badRequest( + `Some lessons don't exist, wanted: ${body.lessonIds.length}, found: ${lessonCount}` + ); } } @@ -1119,9 +883,7 @@ export const updateSubstitution = timetableFactory.createHandlers( ); if (!updatedSubstitution) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: 'Substitution not found', - }); + throw notFound('Substitution not found'); } cancelPendingNotification(id, 'substitution'); @@ -1156,41 +918,13 @@ export const updateSubstitution = timetableFactory.createHandlers( }) ); - return ok(c, updatedSubstitution); - } -); + return updatedSubstitution; + }); -export const deleteSubstitution = timetableFactory.createHandlers( - describeRoute({ - ...filcExt('Substitution', '@nodata', true), - description: 'Delete a substitution', - parameters: [ - { - in: 'path', - name: 'id', - required: true, - schema: { - description: 'The unique identifier for the substitution to delete.', - type: 'string', - }, - }, - ], - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(createResponseSchema), - }, - }, - description: 'Successful Response', - }, - }, - tags: ['Substitution'], - }), - ...authRouter('substitution:delete'), - zValidator('param', substitutionIdParamsSchema), - async (c) => { - const { id } = c.req.valid('param'); +export const deleteSubstitution = base.timetable.substitutions.delete + .use(requireAuthorization(permissions.substitutionDelete)) + .handler(async ({ input }) => { + const { id } = input; const [existingSubstitution] = await db .select() @@ -1199,9 +933,7 @@ export const deleteSubstitution = timetableFactory.createHandlers( .limit(1); if (!existingSubstitution) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: 'Substitution not found', - }); + throw notFound('Substitution not found'); } // The many-to-many relationships will be automatically deleted due to the CASCADE constraint @@ -1210,9 +942,12 @@ export const deleteSubstitution = timetableFactory.createHandlers( .where(eq(substitution.id, id)) .returning(); + if (!deletedSubstitution) { + throw notFound('Substitution not found'); + } + cancelPendingNotification(id, 'substitution'); cancelPendingNotification(id, 'substitution_teacher'); - return ok(c, deletedSubstitution); - } -); + return { id: deletedSubstitution.id }; + }); diff --git a/apps/chronos/src/routes/timetable/teacher.ts b/apps/chronos/src/routes/timetable/teacher.ts index 681e000c..b41adb9f 100644 --- a/apps/chronos/src/routes/timetable/teacher.ts +++ b/apps/chronos/src/routes/timetable/teacher.ts @@ -1,99 +1,39 @@ -import { - getMyTeacherResponseSchema, - getTeacherParamsSchema, - listTeachersResponseSchema, - publicTeacherSchema, - type TeacherListItem, - teacherListItemSchema, - updateTeacherPayload, -} from '@filcdev/api/domains/timetable/teacher'; +import type { TeacherListItem } from '@filcdev/api/domains/timetable/teacher'; import { permissions } from '@filcdev/api/permissions'; -import { zValidator } from '@hono/zod-validator'; import { eq } from 'drizzle-orm'; -import { HTTPException } from 'hono/http-exception'; -import { describeRoute, resolver } from 'hono-openapi'; -import { StatusCodes } from 'http-status-codes'; -import z from 'zod'; import { db } from '#database'; import { user } from '#database/schema/authentication'; import { teacher } from '#database/schema/timetable'; -import { authRouter } from '#middleware/auth'; -import { badRequest, notFound, ok } from '#utils/http'; -import { filcExt } from '#utils/openapi'; -import { timetableFactory } from './_factory'; - -const getTeachersResponseSchema = z.object({ - data: publicTeacherSchema.array(), - success: z.boolean(), -}); - -const updateTeacherBodySchema = ( - await resolver(updateTeacherPayload).toOpenAPISchema() -).schema; - -const updateTeacherResponseSchema = z.object({ - data: teacherListItemSchema, - success: z.boolean(), -}); +import { requireAuthentication, requireAuthorization } from '#middleware/auth'; +import { base } from '#orpc'; +import { badRequest, notFound } from '#utils/http'; /** * Public teacher list used by the timetable filter bars and substitution * pickers. Projects only non-sensitive columns; email and the linked user stay * behind the admin endpoints. */ -export const getTeachers = timetableFactory.createHandlers( - describeRoute({ - ...filcExt('Teacher', '@listof Teacher'), - description: 'Get all teachers from the database.', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(getTeachersResponseSchema), - }, - }, - description: 'Successful Response', - }, - }, - tags: ['Teacher'], - }), - async (c) => { - const teachers = await db - .select({ - firstName: teacher.firstName, - id: teacher.id, - lastName: teacher.lastName, - short: teacher.short, - }) - .from(teacher); - - return ok(c, teachers); - } -); +export const getTeachers = base.timetable.teachers.getAll.handler(async () => { + const teachers = await db + .select({ + firstName: teacher.firstName, + id: teacher.id, + lastName: teacher.lastName, + short: teacher.short, + }) + .from(teacher); + + return teachers; +}); /** * The signed-in user's linked teacher, or null when the account isn't tied to * a teacher row. Lets teacher accounts default to the teacher view. */ -export const getMyTeacher = timetableFactory.createHandlers( - describeRoute({ - ...filcExt('Teacher', '@unit Teacher', true), - description: "Get the signed-in user's linked teacher, if any.", - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(getMyTeacherResponseSchema), - }, - }, - description: 'Successful Response', - }, - }, - tags: ['Teacher'], - }), - ...authRouter(), - async (c) => { - const userId = c.get('user').id; +export const getMyTeacher = base.timetable.teachers.me + .use(requireAuthentication) + .handler(async ({ context }) => { + const userId = context.session.userId; const [row] = await db .select({ @@ -107,30 +47,14 @@ export const getMyTeacher = timetableFactory.createHandlers( .orderBy(teacher.id) .limit(1); - c.header('Cache-Control', 'no-store'); - return ok(c, row ?? null); - } -); + context.resHeaders?.set('Cache-Control', 'no-store'); + return row ?? null; + }); /** Admin teacher list, including email and the linked user account. */ -export const listTeachersAdmin = timetableFactory.createHandlers( - describeRoute({ - ...filcExt('Teacher', '@listof @unit TeacherListItem', true), - description: 'List all teachers with their email and linked user.', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(listTeachersResponseSchema), - }, - }, - description: 'Successful Response', - }, - }, - tags: ['Teacher'], - }), - ...authRouter(permissions.teacherManage), - async (c) => { +export const listTeachersAdmin = base.timetable.teachers.list + .use(requireAuthorization(permissions.teacherManage)) + .handler(async () => { const rows = await db .select({ email: teacher.email, @@ -161,52 +85,14 @@ export const listTeachersAdmin = timetableFactory.createHandlers( userId: row.userId, })); - return ok(c, data); - } -); + return data; + }); /** Manually set a teacher's email and/or linked user. */ -export const updateTeacher = timetableFactory.createHandlers( - describeRoute({ - ...filcExt('Teacher', '@unit TeacherListItem', true), - description: 'Update a teacher email and/or linked user.', - parameters: [ - { - in: 'path', - name: 'id', - required: true, - schema: { - format: 'uuid', - type: 'string', - }, - }, - ], - requestBody: { - content: { - 'application/json': { - schema: updateTeacherBodySchema, - }, - }, - required: true, - }, - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(updateTeacherResponseSchema), - }, - }, - description: 'Successful Response', - }, - }, - tags: ['Teacher'], - }), - ...authRouter(permissions.teacherManage), - zValidator('param', getTeacherParamsSchema), - zValidator('json', updateTeacherPayload), - async (c) => { - const { id } = c.req.valid('param'); - const body = c.req.valid('json'); +export const updateTeacher = base.timetable.teachers.update + .use(requireAuthorization(permissions.teacherManage)) + .handler(async ({ input }) => { + const { id, ...body } = input; const [existing] = await db .select({ id: teacher.id }) @@ -262,9 +148,7 @@ export const updateTeacher = timetableFactory.createHandlers( .limit(1); if (!updated) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: 'Teacher not found', - }); + throw notFound('Teacher not found'); } const data: TeacherListItem = { @@ -283,6 +167,5 @@ export const updateTeacher = timetableFactory.createHandlers( userId: updated.userId, }; - return ok(c, data); - } -); + return data; + }); diff --git a/apps/chronos/src/routes/users/_factory.ts b/apps/chronos/src/routes/users/_factory.ts deleted file mode 100644 index 9a32ee94..00000000 --- a/apps/chronos/src/routes/users/_factory.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { createFactory } from 'hono/factory'; -import type { Context } from '#_types/globals'; - -export const usersFactory = createFactory(); diff --git a/apps/chronos/src/routes/users/_router.ts b/apps/chronos/src/routes/users/_router.ts index 5838b131..049178cd 100644 --- a/apps/chronos/src/routes/users/_router.ts +++ b/apps/chronos/src/routes/users/_router.ts @@ -1,4 +1,3 @@ -import { usersFactory } from '#routes/users/_factory'; import { createApiKeyRoute, listApiKeysRoute, @@ -6,11 +5,14 @@ import { } from '#routes/users/api-keys'; import { listUsers, updateUser } from '#routes/users/index'; -export const usersRouter = usersFactory - .createApp() - .get('/', ...listUsers) - .patch('/:id', ...updateUser) - // API key management (scoped to the authenticated user) - .get('/me/api-keys', ...listApiKeysRoute) - .post('/me/api-keys', ...createApiKeyRoute) - .delete('/me/api-keys/:id', ...revokeApiKeyRoute); +export const usersRouter = { + list: listUsers, + me: { + apiKeys: { + create: createApiKeyRoute, + delete: revokeApiKeyRoute, + list: listApiKeysRoute, + }, + }, + update: updateUser, +}; diff --git a/apps/chronos/src/routes/users/api-keys.ts b/apps/chronos/src/routes/users/api-keys.ts index 0b000aa4..36fec6ef 100644 --- a/apps/chronos/src/routes/users/api-keys.ts +++ b/apps/chronos/src/routes/users/api-keys.ts @@ -1,126 +1,48 @@ -import { - apiKeyIdParamsSchema, - createApiKeySchema, -} from '@filcdev/api/domains/api-keys'; -import { zValidator } from '@hono/zod-validator'; import { getLogger } from '@logtape/logtape'; +import { ORPCError } from '@orpc/server'; import { and, desc, eq } from 'drizzle-orm'; -import { HTTPException } from 'hono/http-exception'; -import { describeRoute, resolver } from 'hono-openapi'; -import { StatusCodes } from 'http-status-codes'; -import z from 'zod'; -import type { SuccessResponse } from '#_types/globals'; import { db } from '#database'; import { apiKey } from '#database/schema/api-keys'; import { requireAuthentication } from '#middleware/auth'; +import { base } from '#orpc'; import { generateApiKey } from '#utils/api-keys'; -import { filcExt } from '#utils/openapi'; -import { createSelectSchema } from '#utils/zod'; -import { usersFactory } from './_factory'; +import { notFound } from '#utils/http'; const logger = getLogger(['chronos', 'users', 'api-keys']); -// Never leak the hash or the raw key in listings. -const apiKeySelectSchema = createSelectSchema(apiKey).omit({ - keyHash: true, -}); - -const { schema: createApiKeyRequestSchema } = - await resolver(createApiKeySchema).toOpenAPISchema(); - -const listApiKeysResponseSchema = z.object({ - data: z.object({ - apiKeys: z.array(apiKeySelectSchema), - }), - success: z.literal(true), -}); - -const createApiKeyResponseSchema = z.object({ - data: z.object({ - apiKey: apiKeySelectSchema, - // The raw secret is only returned here, at creation time. - rawKey: z.string(), - }), - success: z.literal(true), -}); - -export const listApiKeysRoute = usersFactory.createHandlers( - describeRoute({ - ...filcExt( - 'Users', - '@unit ApiKeyListResponse @field(.apiKeys, List)', - true - ), - description: 'List the API keys belonging to the authenticated user', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(listApiKeysResponseSchema), - }, - }, - description: 'Successful response', - }, - }, - tags: ['Users'], - }), - requireAuthentication, - async (c) => { - const session = c.var.session; - if (!session) { - throw new HTTPException(StatusCodes.UNAUTHORIZED); - } +// Never leak the hash or the raw key in listings: the rows are projected to +// exactly the fields the contract exposes. +const apiKeyColumns = { + createdAt: apiKey.createdAt, + expiresAt: apiKey.expiresAt, + id: apiKey.id, + lastUsedAt: apiKey.lastUsedAt, + name: apiKey.name, + prefix: apiKey.prefix, + updatedAt: apiKey.updatedAt, + userId: apiKey.userId, +}; + +export const listApiKeysRoute = base.users.me.apiKeys.list + .use(requireAuthentication) + .handler(async ({ context }) => { + const session = context.session; const keys = await db - .select() + .select(apiKeyColumns) .from(apiKey) .where(eq(apiKey.userId, session.userId)) .orderBy(desc(apiKey.createdAt)); - return c.json>({ - data: { apiKeys: keys }, - success: true, - }); - } -); - -export const createApiKeyRoute = usersFactory.createHandlers( - describeRoute({ - ...filcExt( - 'Users', - '@unit ApiKeyResponse @field(.apiKey, ApiKey) @field(.rawKey, String)', - true - ), - description: - 'Create a new API key for the authenticated user. The raw key is only returned in this response.', - requestBody: { - content: { - 'application/json': { - schema: createApiKeyRequestSchema, - }, - }, - }, - responses: { - 201: { - content: { - 'application/json': { - schema: resolver(createApiKeyResponseSchema), - }, - }, - description: 'API key created', - }, - }, - tags: ['Users'], - }), - requireAuthentication, - zValidator('json', createApiKeySchema), - async (c) => { - const session = c.var.session; - if (!session) { - throw new HTTPException(StatusCodes.UNAUTHORIZED); - } + return { apiKeys: keys }; + }); - const { name, expiresAt } = c.req.valid('json'); +export const createApiKeyRoute = base.users.me.apiKeys.create + .use(requireAuthentication) + .handler(async ({ context, input }) => { + const session = context.session; + + const { name, expiresAt } = input; const { hash, key, prefix } = generateApiKey(); const [inserted] = await db @@ -132,12 +54,10 @@ export const createApiKeyRoute = usersFactory.createHandlers( prefix, userId: session.userId, }) - .returning(); + .returning(apiKeyColumns); if (!inserted) { - throw new HTTPException(StatusCodes.INTERNAL_SERVER_ERROR, { - message: 'Failed to create API key', - }); + throw new ORPCError('INTERNAL', { message: 'Failed to create API key' }); } logger.info('Created API key', { @@ -145,49 +65,25 @@ export const createApiKeyRoute = usersFactory.createHandlers( userId: session.userId, }); - return c.json>( - { - data: { apiKey: inserted, rawKey: key }, - success: true, - }, - StatusCodes.CREATED - ); - } -); - -export const revokeApiKeyRoute = usersFactory.createHandlers( - describeRoute({ - ...filcExt('Users', '@nodata', true), - description: 'Revoke (delete) an API key owned by the authenticated user', - responses: { - 200: { description: 'API key revoked' }, - 404: { description: 'API key not found' }, - }, - tags: ['Users'], - }), - requireAuthentication, - zValidator('param', apiKeyIdParamsSchema), - async (c) => { - const session = c.var.session; - if (!session) { - throw new HTTPException(StatusCodes.UNAUTHORIZED); - } + return { apiKey: inserted, rawKey: key }; + }); - const { id } = c.req.valid('param'); +export const revokeApiKeyRoute = base.users.me.apiKeys.delete + .use(requireAuthentication) + .handler(async ({ context, input }) => { + const session = context.session; + const { id } = input; const [deleted] = await db .delete(apiKey) .where(and(eq(apiKey.id, id), eq(apiKey.userId, session.userId))) - .returning(); + .returning({ id: apiKey.id }); if (!deleted) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: 'API key not found', - }); + throw notFound('API key not found'); } logger.info('Revoked API key', { id, userId: session.userId }); - return c.json({ success: true }); - } -); + return { id: deleted.id }; + }); diff --git a/apps/chronos/src/routes/users/index.ts b/apps/chronos/src/routes/users/index.ts index 27f5d20e..5af786ef 100644 --- a/apps/chronos/src/routes/users/index.ts +++ b/apps/chronos/src/routes/users/index.ts @@ -1,68 +1,16 @@ -import { - listUsersQuerySchema, - userUpdatePayload, -} from '@filcdev/api/domains/users'; -import { zValidator } from '@hono/zod-validator'; +import { permissions } from '@filcdev/api/permissions'; import { count, desc, eq, ilike, or } from 'drizzle-orm'; -import { HTTPException } from 'hono/http-exception'; -import { describeRoute, resolver } from 'hono-openapi'; -import { StatusCodes } from 'http-status-codes'; -import z from 'zod'; import { db } from '#database'; import { user } from '#database/schema/authentication'; -import { authRouter } from '#middleware/auth'; -import { usersFactory } from '#routes/users/_factory'; +import { requireAuthorization } from '#middleware/auth'; +import { base } from '#orpc'; import { getUserPermissionsBulk } from '#utils/authorization'; -import { ok } from '#utils/http'; -import { filcExt } from '#utils/openapi'; -import { createSelectSchema } from '#utils/zod'; +import { notFound } from '#utils/http'; -const updateUserBodySchema = ( - await resolver(userUpdatePayload).toOpenAPISchema() -).schema; - -const userSchema = createSelectSchema(user).extend({ - displayName: z.string(), - permissions: z.array(z.string()), -}); - -const listUsersResponseSchema = z.object({ - data: z.object({ - total: z.number(), - users: z.array(userSchema), - }), - success: z.boolean(), -}); - -const updateUserResponseSchema = z.object({ - data: userSchema, - success: z.boolean(), -}); - -export const listUsers = usersFactory.createHandlers( - describeRoute({ - ...filcExt( - 'Users', - '@unit UserListResponse @field(.users, List) @field(.total, Int)', - true - ), - description: 'List users', - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(listUsersResponseSchema), - }, - }, - description: 'List of users', - }, - }, - tags: ['Users'], - }), - ...authRouter('users:manage'), - zValidator('query', listUsersQuerySchema), - async (c) => { - const { limit, offset, search } = c.req.valid('query'); +export const listUsers = base.users.list + .use(requireAuthorization(permissions.usersManage)) + .handler(async ({ input }) => { + const { limit, offset, search } = input; const whereClause = search ? or( @@ -95,41 +43,13 @@ export const listUsers = usersFactory.createHandlers( .from(user) .where(whereClause); - return ok(c, { total: countResult?.count ?? 0, users }); - } -); + return { total: countResult?.count ?? 0, users }; + }); -export const updateUser = usersFactory.createHandlers( - describeRoute({ - ...filcExt('Users', '@unit User', true), - description: 'Update user', - requestBody: { - content: { - 'application/json': { schema: updateUserBodySchema }, - }, - }, - responses: { - 200: { - content: { - 'application/json': { - schema: resolver(updateUserResponseSchema), - }, - }, - description: 'User updated', - }, - }, - tags: ['Users'], - }), - ...authRouter('users:manage'), - zValidator('json', userUpdatePayload), - async (c) => { - const userId = c.req.param('id'); - if (!userId) { - throw new HTTPException(StatusCodes.BAD_REQUEST, { - message: 'User ID is required', - }); - } - const { cohortId, nickname, roles } = c.req.valid('json'); +export const updateUser = base.users.update + .use(requireAuthorization(permissions.usersManage)) + .handler(async ({ input }) => { + const { id: userId, cohortId, nickname, roles } = input; const [updatedUser] = await db .update(user) @@ -142,11 +62,16 @@ export const updateUser = usersFactory.createHandlers( .returning(); if (!updatedUser) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: 'User not found', - }); + throw notFound('User not found'); } - return ok(c, updatedUser); - } -); + const permissionsByUser = await getUserPermissionsBulk([updatedUser.id]); + + return { + ...updatedUser, + displayName: updatedUser.nickname + ? updatedUser.nickname + : updatedUser.name || 'Unknown user', + permissions: permissionsByUser.get(updatedUser.id) ?? [], + }; + }); diff --git a/apps/chronos/src/utils/authentication.ts b/apps/chronos/src/utils/authentication.ts index 9cee3119..d41239d7 100644 --- a/apps/chronos/src/utils/authentication.ts +++ b/apps/chronos/src/utils/authentication.ts @@ -1,10 +1,9 @@ +import { userAdditionalFields } from '@filcdev/auth'; import { getLogger } from '@logtape/logtape'; import { type BetterAuthOptions, betterAuth } from 'better-auth'; import { drizzleAdapter } from 'better-auth/adapters/drizzle'; import { customSession, oAuthProxy } from 'better-auth/plugins'; import { and, eq, isNull, or, sql } from 'drizzle-orm'; -import { Hono } from 'hono'; -import type { Context } from '#_types/globals'; import { db } from '#database'; import { authenticationSchema, @@ -186,24 +185,7 @@ const authOptions = { }, trustedOrigins: env.trustedOrigins ?? [env.baseUrl], user: { - additionalFields: { - cohortId: { - input: true, - required: false, - type: 'string', - }, - nickname: { - input: true, - required: false, - type: 'string', - }, - roles: { - defaultValue: ['user'], - input: false, - required: true, - type: 'string[]', - }, - }, + additionalFields: userAdditionalFields, }, } satisfies BetterAuthOptions; @@ -228,9 +210,5 @@ export const auth = betterAuth({ ], }); -export const authRouter = new Hono().on(['POST', 'GET'], '*', (c) => - auth.handler(c.req.raw) -); - export type Session = typeof auth.$Infer.Session; export type User = (typeof auth.$Infer.Session)['user']; diff --git a/apps/chronos/src/utils/doorlock/device-sync.ts b/apps/chronos/src/utils/doorlock/device-sync.ts index 843a43d8..4fe42ccb 100644 --- a/apps/chronos/src/utils/doorlock/device-sync.ts +++ b/apps/chronos/src/utils/doorlock/device-sync.ts @@ -1,5 +1,5 @@ import { getLogger } from '@logtape/logtape'; -import { syncDatabase } from '#routes/doorlock/websocket-handler'; +import { syncDatabase } from '#routes/doorlock/device-socket'; const logger = getLogger(['chronos', 'doorlock', 'sync']); diff --git a/apps/chronos/src/utils/http.ts b/apps/chronos/src/utils/http.ts index f79c2d00..2358ad54 100644 --- a/apps/chronos/src/utils/http.ts +++ b/apps/chronos/src/utils/http.ts @@ -1,64 +1,40 @@ -import { type ErrorCode, errorCodeForStatus } from '@filcdev/api/errors'; -import type { Context } from 'hono'; -import { HTTPException } from 'hono/http-exception'; -import type { ContentfulStatusCode } from 'hono/utils/http-status'; -import { StatusCodes } from 'http-status-codes'; -import type { SuccessResponse } from '#_types/globals'; - -type Envelope = SuccessResponse & Record; - -/** - * An HTTP exception carrying the machine-readable error code that - * `api.onError` serializes into the error envelope. Codes default to the - * status-derived mapping; pass `code` when the status alone is ambiguous. - */ -export class ApiHttpError extends HTTPException { - readonly code: ErrorCode; - - constructor( - status: ContentfulStatusCode, - options: { cause?: unknown; code?: ErrorCode; message: string } - ) { - super(status, { cause: options.cause, message: options.message }); - this.name = 'ApiHttpError'; - this.code = options.code ?? errorCodeForStatus(status); - } -} - -/** Read the error code off any thrown error, falling back to the status. */ -export const errorCodeOf = (err: HTTPException): ErrorCode => - err instanceof ApiHttpError ? err.code : errorCodeForStatus(err.status); +import { apiErrors } from '@filcdev/api/errors'; +import { createORPCErrorConstructorMap, ORPCError } from '@orpc/server'; /** - * Respond with a success envelope. Keeps the `{ data, success: true }` shape - * that the frontend `parseResponse` helper expects, without handlers having to - * annotate `c.json>` by hand. `extra` is spread into the - * envelope (e.g. `{ total }` for paginated responses). + * Error factories built from the shared contract error map, so every thrown + * error carries its code's HTTP status and default message and comes back to + * clients as a *defined* error (`isDefinedError(error) === true`), which is + * what lets callers branch on `error.code`. */ -export const ok = ( - c: C, - data: T, - status: ContentfulStatusCode = StatusCodes.OK as ContentfulStatusCode, - extra: Record = {} -) => c.json>({ data, success: true, ...extra }, status); +const errors = createORPCErrorConstructorMap(apiErrors); -export const created = (c: C, data: T) => - ok(c, data, StatusCodes.CREATED as ContentfulStatusCode); +export const notFound = (message = 'Not found', cause?: unknown) => + errors.NOT_FOUND({ cause, message }); -export const noContent = (c: C) => - c.json({ success: true }, StatusCodes.NO_CONTENT as ContentfulStatusCode); +export const conflict = (message: string, cause?: unknown) => + errors.CONFLICT({ cause, message }); export const badRequest = (message: string, cause?: unknown) => - new ApiHttpError(StatusCodes.BAD_REQUEST, { cause, message }); - -export const notFound = (message = 'Not found', cause?: unknown) => - new ApiHttpError(StatusCodes.NOT_FOUND, { cause, message }); + errors.VALIDATION({ cause, message }); export const forbidden = (message = 'Forbidden', cause?: unknown) => - new ApiHttpError(StatusCodes.FORBIDDEN, { cause, message }); + errors.FORBIDDEN({ cause, message }); export const unauthorized = (message = 'Unauthorized', cause?: unknown) => - new ApiHttpError(StatusCodes.UNAUTHORIZED, { cause, message }); + errors.UNAUTHORIZED({ cause, message }); -export const conflict = (message: string, cause?: unknown) => - new ApiHttpError(StatusCodes.CONFLICT, { cause, message }); +/** + * 503: the dependency behind the endpoint (object storage, an upstream API) is + * missing. Deliberately outside the shared map — the status differs from + * `INTERNAL`'s, so it stays an undocumented, non-defined error. + */ +export const serviceUnavailable = (message: string, cause?: unknown) => + new ORPCError('INTERNAL', { cause, message, status: 503 }); + +/** + * 502: an upstream the endpoint proxies (weather, departures, an RSS feed) + * answered with an error. Same reasoning as `serviceUnavailable`. + */ +export const badGateway = (message: string, cause?: unknown) => + new ORPCError('INTERNAL', { cause, message, status: 502 }); diff --git a/apps/chronos/src/utils/kiosk/feed-url.ts b/apps/chronos/src/utils/kiosk/feed-url.ts index 3a58aced..31df8662 100644 --- a/apps/chronos/src/utils/kiosk/feed-url.ts +++ b/apps/chronos/src/utils/kiosk/feed-url.ts @@ -1,6 +1,5 @@ import { lookup } from 'node:dns/promises'; -import { StatusCodes } from 'http-status-codes'; -import { ApiHttpError } from '#utils/http'; +import { badRequest } from '#utils/http'; const IPV4_OCTET_RE = /^\d{1,3}$/; const IPV6_HEXTET_RE = /^[0-9a-f]{1,4}$/; @@ -226,10 +225,7 @@ export function isPrivateAddress(address: string): boolean { return false; } -const feedNotAllowed = () => - new ApiHttpError(StatusCodes.BAD_REQUEST, { - message: 'Feed URL is not allowed', - }); +const feedNotAllowed = () => badRequest('Feed URL is not allowed'); /** * SSRF protection for the public petrik-news endpoint: only https URLs whose diff --git a/apps/chronos/src/utils/kiosk/schemas.ts b/apps/chronos/src/utils/kiosk/schemas.ts deleted file mode 100644 index ddb792cb..00000000 --- a/apps/chronos/src/utils/kiosk/schemas.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { kioskKindSchema } from '@filcdev/api/domains/kiosk/config'; -import z from 'zod'; -import { kiosk } from '#database/schema/kiosk'; -import { createSelectSchema } from '#utils/zod'; - -export const kioskSelectSchema = createSelectSchema(kiosk); - -const kioskSummarySchema = z.object({ - id: z.uuid(), - kind: kioskKindSchema, - name: z.string(), -}); - -export const kioskListResponseSchema = z.object({ - data: z.object({ kiosks: z.array(kioskSelectSchema) }), - success: z.literal(true), -}); - -export const kioskResponseSchema = z.object({ - data: z.object({ kiosk: kioskSelectSchema }), - success: z.literal(true), -}); - -/** - * The heartbeat has three shapes: an unknown box gets `registered: false`, a - * known box gets its kind/name (plus `config` and `enabled: true` when it is - * enabled). `config` is deliberately absent for unknown and disabled boxes. - */ -export const kioskHeartbeatResponseSchema = z.object({ - data: z.object({ - enabled: z.boolean().optional(), - kiosk: kioskSummarySchema - .extend({ config: z.unknown().optional() }) - .optional(), - registered: z.boolean(), - }), - success: z.literal(true), -}); - -export const kioskNewsResponseSchema = z.object({ - data: z.object({ - announcements: z.array( - z.object({ - body: z.string(), - highlighted: z.boolean(), - id: z.uuid(), - imageVersion: z.string().nullable(), - /** Boxes the takeover is limited to; empty means every kiosk. */ - kioskIds: z.array(z.string()), - title: z.string(), - validFrom: z.date(), - validUntil: z.date(), - }) - ), - }), - success: z.literal(true), -}); - -export const kioskWeatherResponseSchema = z.object({ - data: z.object({ - icon: z.string(), - precipMm: z.number(), - tempC: z.number(), - windKph: z.number(), - }), - success: z.literal(true), -}); - -export const kioskPetrikNewsResponseSchema = z.object({ - data: z.object({ - items: z.array( - z.object({ - body: z.string(), - imageUrl: z.string().nullable(), - publishedAt: z.string(), - title: z.string(), - url: z.string(), - }) - ), - }), - success: z.literal(true), -}); - -export const kioskDeparturesResponseSchema = z.object({ - data: z.object({ - departures: z.array( - z - .object({ - label: z.string(), - predictedDepartureTime: z.number(), - routeShortDesc: z.string(), - }) - .nullable() - ), - }), - success: z.literal(true), -}); diff --git a/apps/chronos/src/utils/news/cohort.ts b/apps/chronos/src/utils/news/cohort.ts index ec98c8f7..41c543a0 100644 --- a/apps/chronos/src/utils/news/cohort.ts +++ b/apps/chronos/src/utils/news/cohort.ts @@ -1,8 +1,7 @@ import { sql } from 'drizzle-orm'; -import { HTTPException } from 'hono/http-exception'; -import { StatusCodes } from 'http-status-codes'; import { db } from '#database'; import { cohort } from '#database/schema/timetable'; +import { badRequest } from '#utils/http'; /** Throw 400 if any of the provided cohort IDs do not exist. */ export const validateCohortIds = async (cohortIds: string[]) => { @@ -13,8 +12,6 @@ export const validateCohortIds = async (cohortIds: string[]) => { const existingIds = new Set(existingCohorts.map((co) => co.id)); const invalid = cohortIds.filter((cid) => !existingIds.has(cid)); if (invalid.length > 0) { - throw new HTTPException(StatusCodes.BAD_REQUEST, { - message: `Invalid cohort IDs: ${invalid.join(', ')}`, - }); + throw badRequest(`Invalid cohort IDs: ${invalid.join(', ')}`); } }; diff --git a/apps/chronos/src/utils/news/kiosks.ts b/apps/chronos/src/utils/news/kiosks.ts index a34da203..833a7437 100644 --- a/apps/chronos/src/utils/news/kiosks.ts +++ b/apps/chronos/src/utils/news/kiosks.ts @@ -1,8 +1,7 @@ import { sql } from 'drizzle-orm'; -import { HTTPException } from 'hono/http-exception'; -import { StatusCodes } from 'http-status-codes'; import { db } from '#database'; import { kiosk } from '#database/schema/kiosk'; +import { badRequest } from '#utils/http'; /** Throw 400 if any of the provided kiosk IDs do not exist. */ export const validateKioskIds = async (kioskIds: string[]) => { @@ -13,8 +12,6 @@ export const validateKioskIds = async (kioskIds: string[]) => { const existingIds = new Set(existingKiosks.map((row) => row.id)); const invalid = kioskIds.filter((id) => !existingIds.has(id)); if (invalid.length > 0) { - throw new HTTPException(StatusCodes.BAD_REQUEST, { - message: `Invalid kiosk IDs: ${invalid.join(', ')}`, - }); + throw badRequest(`Invalid kiosk IDs: ${invalid.join(', ')}`); } }; diff --git a/apps/chronos/src/utils/openapi-spec.ts b/apps/chronos/src/utils/openapi-spec.ts new file mode 100644 index 00000000..c4805ff7 --- /dev/null +++ b/apps/chronos/src/utils/openapi-spec.ts @@ -0,0 +1,49 @@ +import type { OpenAPIGeneratorGenerateOptions } from '@orpc/openapi'; + +/** + * better-auth prefixes its cookies with `authOptions.advanced.cookiePrefix` + * (`filc`), so the session cookie is `filc.session_token` — the name the + * generated external clients authenticate with. + */ +export const SESSION_COOKIE_NAME = 'filc.session_token'; + +/** + * The one place the OpenAPI document is configured. `src/index.ts` serves it at + * `/api/doc/openapi.json` and `scripts/generate-openapi.ts` writes it to + * `openapi/chronos-openapi.json`, so the committed file *is* the served + * document — the same generator input, no per-consumer post-processing. + */ +export const openApiSpecOptions = { + components: { + securitySchemes: { + sessionAuth: { in: 'cookie', name: SESSION_COOKIE_NAME, type: 'apiKey' }, + }, + }, + /** + * One error shape for every status. The default shaper emits a `oneOf` per + * defined code, which clients (and openapi-generator's Kotlin output) handle + * badly; this keeps the codes, message and status and drops the union. + */ + customErrorResponseBodySchema: () => ({ + properties: { + code: { type: 'string' }, + message: { type: 'string' }, + status: { type: 'integer' }, + }, + required: ['code', 'message', 'status'], + type: 'object', + }), + // The notifications endpoints are internal to the apps: they predate the + // OpenAPI metadata convention, so they are procedures without a public + // description. Drop the `filter` to publish them. + filter: ({ path }) => path[0] !== 'notifications', + info: { + description: 'API for consumption by the Filc app family.', + title: 'Chronos backend API', + version: '0.0.1', + }, + servers: [ + { description: 'chronos', url: 'https://filc.petrik.hu/api' }, + { description: 'Local Server', url: 'http://localhost:3000/api' }, + ], +} satisfies OpenAPIGeneratorGenerateOptions; diff --git a/apps/chronos/src/utils/openapi.ts b/apps/chronos/src/utils/openapi.ts deleted file mode 100644 index f9db3000..00000000 --- a/apps/chronos/src/utils/openapi.ts +++ /dev/null @@ -1,8 +0,0 @@ -// Spreading the result of this function into a describeRoute() object bypasses -// TypeScript's excess property checking (only fresh object literals are checked), -// while still embedding the x-filc_* values in the OpenAPI output at runtime. -export const filcExt = (groupName: string, typeName: string, auth = false) => ({ - 'x-filc_group_name': groupName, - 'x-filc_type_name': typeName, - ...(auth && { 'x-filc_auth': 'true' }), -}); diff --git a/apps/chronos/src/utils/telemetry.ts b/apps/chronos/src/utils/telemetry.ts index 7873bddc..75e51b49 100644 --- a/apps/chronos/src/utils/telemetry.ts +++ b/apps/chronos/src/utils/telemetry.ts @@ -1,5 +1,5 @@ import { getLogger } from '@logtape/logtape'; -import { honoIntegration, init, setUser } from '@sentry/bun'; +import { init, setUser } from '@sentry/bun'; import type { Session, User } from 'better-auth'; import { env } from '#utils/environment'; @@ -49,8 +49,6 @@ export const initSentry = () => { }, }, - // Integrations - integrations: [honoIntegration()], release, sampleRate: env.sentrySampleRate, diff --git a/apps/iris/Dockerfile b/apps/iris/Dockerfile index 41ff88f9..a8dc63f1 100644 --- a/apps/iris/Dockerfile +++ b/apps/iris/Dockerfile @@ -16,10 +16,13 @@ COPY --from=prepare /app/out/full/ . RUN bun run build --filter=@filcdev/iris -FROM nginx:1.31-alpine AS release -COPY --from=builder /app/apps/iris/nginx.conf /etc/nginx/nginx.conf -COPY --from=builder /app/apps/iris/dist /usr/share/nginx/html -EXPOSE 80/tcp +FROM base AS release +ENV NODE_ENV=production +ENV PORT=3000 +COPY --from=builder /app/apps/iris/dist ./dist +COPY --from=builder /app/apps/iris/server.ts ./server.ts +USER bun +EXPOSE 3000/tcp HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ - CMD wget -q --spider http://127.0.0.1/healthz || exit 1 -ENTRYPOINT ["nginx", "-g", "daemon off;"] + CMD wget -q --spider http://127.0.0.1:${PORT}/healthz || exit 1 +ENTRYPOINT ["bun", "run", "server.ts"] diff --git a/apps/iris/index.html b/apps/iris/index.html deleted file mode 100644 index 09e29e38..00000000 --- a/apps/iris/index.html +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - Filc - - - -
- - - - \ No newline at end of file diff --git a/apps/iris/nginx.conf b/apps/iris/nginx.conf deleted file mode 100644 index d5541663..00000000 --- a/apps/iris/nginx.conf +++ /dev/null @@ -1,33 +0,0 @@ -user nginx; -worker_processes auto; - -events { - worker_connections 1024; -} - -http { - include /etc/nginx/mime.types; - default_type application/octet-stream; - - sendfile on; - keepalive_timeout 65; - - server { - listen 80; - server_name _; - - root /usr/share/nginx/html; - index index.html; - - location = /healthz { - access_log off; - return 200; - } - - location / { - try_files $uri $uri/ /index.html; - } - - error_page 404 /index.html; - } -} diff --git a/apps/iris/package.json b/apps/iris/package.json index 7ad67249..47bee045 100644 --- a/apps/iris/package.json +++ b/apps/iris/package.json @@ -5,12 +5,15 @@ "scripts": { "dev": "bun --bun vite", "build": "bun --bun vite build", + "start": "bun run server.ts", "typecheck": "tsc" }, "dependencies": { "@filcdev/navigator-3d": "workspace:*", + "@filcdev/auth": "workspace:*", "@filcdev/ui": "workspace:*", "@fontsource-variable/outfit": "^5.3.0", + "@orpc/client": "catalog:", "@react-pdf/renderer": "^4.9.0", "@sentry/react": "^10.73.0", "@sentry/vite-plugin": "^5.4.0", @@ -18,13 +21,12 @@ "@tanstack/react-devtools": "^0.10.12", "@tanstack/react-form": "^1.33.5", "@tanstack/react-query": "^5.102.8", - "@tanstack/react-router": "^1.170.32", + "@tanstack/react-router": "1.170.40", "@tanstack/react-router-devtools": "^1.167.1", - "@tanstack/router-plugin": "^1.168.35", + "@tanstack/react-router-ssr-query": "1.167.3", + "@tanstack/react-start": "1.168.59", "better-auth": "catalog:", - "date-fns": "^4.4.0", "dayjs": "^1.11.23", - "hono": "catalog:", "i18next": "^26.4.2", "i18next-browser-languagedetector": "^8.2.1", "i18next-http-backend": "^4.0.2", @@ -42,7 +44,6 @@ }, "devDependencies": { "@filcdev/api": "workspace:*", - "@filcdev/chronos": "workspace:*", "@tanstack/devtools-vite": "^0.8.5", "@tsconfig/strictest": "^2.0.8", "@types/bun": "catalog:", diff --git a/apps/iris/public/healthz b/apps/iris/public/healthz new file mode 100644 index 00000000..e69de29b diff --git a/apps/iris/server.ts b/apps/iris/server.ts new file mode 100644 index 00000000..078ef7a4 --- /dev/null +++ b/apps/iris/server.ts @@ -0,0 +1,31 @@ +import startServer from './dist/server/server.js'; + +/** + * The production server: the built Start handler for pages, and the client + * assets it does not serve itself. + * + * `/api` belongs to Chronos and is routed there by the platform proxy, so it + * is answered here with a 404 rather than a page — a server render that reached + * the API through this process would fetch it from itself, forever. + */ +const port = Number(Bun.env.PORT ?? 3000); + +Bun.serve({ + fetch: async (request) => { + const { pathname } = new URL(request.url); + + if (pathname.startsWith('/api/')) { + return new Response('Not found', { status: 404 }); + } + + if (pathname !== '/') { + const asset = Bun.file(`dist/client${pathname}`); + if (await asset.exists()) { + return new Response(asset); + } + } + + return startServer.fetch(request); + }, + port, +}); diff --git a/apps/iris/src/client.tsx b/apps/iris/src/client.tsx new file mode 100644 index 00000000..a34889c9 --- /dev/null +++ b/apps/iris/src/client.tsx @@ -0,0 +1,26 @@ +import { reactErrorHandler } from '@sentry/react'; +import { StartClient } from '@tanstack/react-start/client'; +import { StrictMode } from 'react'; +import { hydrateRoot } from 'react-dom/client'; +import { initializeTelemetry } from '@/utils/telemetry'; +import { reportWebVitals } from '@/utils/web-vitals'; + +import './global.css'; + +initializeTelemetry(); + +hydrateRoot( + document, + + + , + { + onCaughtError: reactErrorHandler(), + onRecoverableError: reactErrorHandler(), + onUncaughtError: reactErrorHandler((_error, _errorInfo) => { + // TODO: logger + }), + } +); + +reportWebVitals(); diff --git a/apps/iris/src/components/admin/admin.types.ts b/apps/iris/src/components/admin/admin.types.ts index 3d18fca9..ee12bf47 100644 --- a/apps/iris/src/components/admin/admin.types.ts +++ b/apps/iris/src/components/admin/admin.types.ts @@ -1,6 +1,7 @@ /** * Shared type definitions for admin components. - * API-derived types (InferResponseType etc.) remain co-located with their components. + * API-derived types (`Awaited>`) remain co-located + * with their components. */ /** Common props shared by all admin dialog components. */ diff --git a/apps/iris/src/components/admin/announcements-dialog.tsx b/apps/iris/src/components/admin/announcements-dialog.tsx index d5b2bd1a..faeb4182 100644 --- a/apps/iris/src/components/admin/announcements-dialog.tsx +++ b/apps/iris/src/components/admin/announcements-dialog.tsx @@ -1,4 +1,5 @@ import { permissions } from '@filcdev/api/permissions'; +import { useSession } from '@filcdev/auth/client'; import { Button } from '@filcdev/ui/components/button'; import { Checkbox } from '@filcdev/ui/components/checkbox'; import { DatePicker } from '@filcdev/ui/components/date-picker'; @@ -26,9 +27,8 @@ import { useUploadAnnouncementImage, } from '@/hooks/news'; import { useHasPermission } from '@/hooks/use-has-permission'; -import { authClient } from '@/utils/authentication'; import { getIntlLocale } from '@/utils/date-locale'; -import { apiBaseUrl } from '@/utils/hc'; +import { apiBaseUrl } from '@/utils/orpc'; import type { BaseDialogProps } from './admin.types'; type AnnouncementsDialogProps = BaseDialogProps & { @@ -236,7 +236,7 @@ export function AnnouncementsDialog({ const uploadImage = useUploadAnnouncementImage(); const removeImage = useDeleteAnnouncementImage(); const { data: cohorts = [] } = useCohorts(open); - const { data: session } = authClient.useSession(); + const { data: session } = useSession(); const canManageKiosks = useHasPermission( permissions.kiosksManage, session?.user?.permissions @@ -258,7 +258,7 @@ export function AnnouncementsDialog({ // An upload/removal answers with the row it produced, so the preview can // reflect it right away instead of waiting for the dialog to be reopened. - const imageRow = uploadImage.data?.data ?? removeImage.data?.data ?? item; + const imageRow = uploadImage.data ?? removeImage.data ?? item; const imageKey = imageRow?.imageKey ?? null; const imageUrl = imageKey ? `${apiBaseUrl}/kiosk/news/${imageRow?.id}/image?v=${imageRow?.imageUpdatedAt}` @@ -282,10 +282,10 @@ export function AnnouncementsDialog({ let announcementId = item?.id ?? null; if (item) { - await updateMutation.mutateAsync({ id: item.id, payload }); + await updateMutation.mutateAsync({ ...payload, id: item.id }); } else { const created = await createMutation.mutateAsync(payload); - announcementId = created.data.id; + announcementId = created.id; } // An image picked before the announcement existed rides along with @@ -602,7 +602,7 @@ export function AnnouncementsDialog({ imageUrl={imageUrl} isRemoving={removeImage.isPending} onFileChange={setImageFile} - onRemove={(id) => removeImage.mutate(id)} + onRemove={(id) => removeImage.mutate({ id })} upload={(file, id) => uploadImage.mutateAsync({ file, id })} /> diff --git a/apps/iris/src/components/admin/dashboard.tsx b/apps/iris/src/components/admin/dashboard.tsx index b3982cab..7bcf5b35 100644 --- a/apps/iris/src/components/admin/dashboard.tsx +++ b/apps/iris/src/components/admin/dashboard.tsx @@ -17,7 +17,7 @@ import { } from '@filcdev/ui/components/select'; import { Separator } from '@filcdev/ui/components/separator'; import { Skeleton } from '@filcdev/ui/components/skeleton'; -import type { InferResponseType } from 'hono/client'; +import { useQuery } from '@tanstack/react-query'; import { ArrowLeftRight, ArrowRightLeft, @@ -29,12 +29,10 @@ import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from 'recharts'; import { StatCard } from '@/components/admin/stat-card'; -import { useApiQuery } from '@/utils/api'; -import { api } from '@/utils/hc'; +import { type api, orpc } from '@/utils/orpc'; -type StatsResponse = InferResponseType; -type DashboardStats = NonNullable['stats']; -type ChartPoint = NonNullable[number]; +type DashboardStats = Awaited>['stats']; +type ChartPoint = DashboardStats['chartData'][number]; type ChartTotals = { movedLessons: number; @@ -237,11 +235,8 @@ export function AdminDashboard() { { label: t('dashboard.lastYear'), value: '365' }, ]; - const statsQuery = useApiQuery>( - () => api.dashboard.stats.$get({ query: { days: String(days) } }), - { - queryKey: ['dashboard', 'stats', days] as const, - } + const statsQuery = useQuery( + orpc.dashboard.stats.queryOptions({ input: { days } }) ); const stats: DashboardStats | undefined = statsQuery.data?.stats; diff --git a/apps/iris/src/components/admin/kiosk-dialog.tsx b/apps/iris/src/components/admin/kiosk-dialog.tsx index c45f8e5c..997d7845 100644 --- a/apps/iris/src/components/admin/kiosk-dialog.tsx +++ b/apps/iris/src/components/admin/kiosk-dialog.tsx @@ -9,7 +9,6 @@ import { navigatorKioskConfigSchema, tvKioskConfigSchema, } from '@filcdev/api/domains/kiosk/config'; -import EditorView3D from '@filcdev/navigator-3d/editor-view'; import { Button } from '@filcdev/ui/components/button'; import { Checkbox } from '@filcdev/ui/components/checkbox'; import { @@ -32,9 +31,14 @@ import { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import type { BaseDialogProps } from '@/components/admin/admin.types'; +import { Lazy } from '@/components/lazy'; import { type KioskRow, useCreateKiosk, useUpdateKiosk } from '@/hooks/kiosks'; import { useBuildings, useNavigatorGraph } from '@/hooks/navigator'; +// The canvas pulls in three.js, which the kiosk list must not pay for until +// the dialog is actually opened. +const loadEditorView3D = () => import('@filcdev/navigator-3d/editor-view'); + type DepartureStopForm = { key: string; routeFilter: string; @@ -169,13 +173,11 @@ export function KioskDialog({ kiosk, onOpenChange, open }: KioskDialogProps) { const submit = (value: KioskFormValues, config: unknown) => { if (kiosk) { updateKiosk.mutate({ + config, + enabled: value.enabled, id: kiosk.id, - payload: { - config, - enabled: value.enabled, - kind: value.kind, - name: value.name, - }, + kind: value.kind, + name: value.name, }); return; } @@ -755,10 +757,11 @@ export function KioskDialog({ kiosk, onOpenChange, open }: KioskDialogProps) {
- { if (patch.x !== undefined) { diff --git a/apps/iris/src/components/admin/moved-lesson-dialog.tsx b/apps/iris/src/components/admin/moved-lesson-dialog.tsx index 07c8926d..7d965089 100644 --- a/apps/iris/src/components/admin/moved-lesson-dialog.tsx +++ b/apps/iris/src/components/admin/moved-lesson-dialog.tsx @@ -13,12 +13,11 @@ import { Label } from '@filcdev/ui/components/label'; import { Textarea } from '@filcdev/ui/components/textarea'; import { useForm, useStore } from '@tanstack/react-form'; import { useQuery } from '@tanstack/react-query'; -import { type InferRequestType, parseResponse } from 'hono/client'; import { ArrowRightLeft, Save } from 'lucide-react'; import { useEffect, useId, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import type { ClassroomItem, PeriodItem } from '@/components/timetable/types'; import { - type Classroom, type Cohort, type DayDefinition, type EnrichedLesson, @@ -26,18 +25,16 @@ import { useCreateMovedLesson, useUpdateMovedLesson, } from '@/hooks/moved-lessons'; -import { useApiQuery } from '@/utils/api'; import { getIntlLocale, isMatchingWeekday } from '@/utils/date-locale'; -import { api } from '@/utils/hc'; +import { type api, orpc } from '@/utils/orpc'; import { formatPeriodLabel } from '@/utils/period'; -import { queryKeys } from '@/utils/query-keys'; import type { BaseDialogProps } from './admin.types'; export type MoveMode = 'room' | 'day'; type MovedLessonDialogProps = BaseDialogProps & { allLessons: EnrichedLesson[]; - classrooms: Classroom[]; + classrooms: ClassroomItem[]; cohorts: Cohort[]; days: DayDefinition[]; item?: MovedLessonItem | null; @@ -49,8 +46,6 @@ type MovedLessonDialogProps = BaseDialogProps & { // or as objects (from the per-cohort lessons endpoint); normalise for display. type CohortLike = string | { id: string; name: string; short?: string }; -type Period = NonNullable; - type LessonForLabel = { id: string; classrooms?: Array<{ name?: string; short?: string }>; @@ -254,7 +249,7 @@ function isOriginalSlot(params: { return false; } return ( - params.dateParam === item.movedLesson.date && + params.dateParam === item.movedLesson.date.toISOString().slice(0, 10) && params.formStartingDay === item.movedLesson.startingDay && params.startingPeriod === item.movedLesson.startingPeriod ); @@ -313,9 +308,9 @@ function resolveRoomModeSlot( type RoomOption = { disabled: boolean; label: string; value: string }; function buildRoomOptions(params: { - availableClassrooms?: Classroom[]; + availableClassrooms?: ClassroomItem[]; availabilityKnown: boolean; - classrooms: Classroom[]; + classrooms: ClassroomItem[]; isStoredSlot: boolean; item?: MovedLessonItem | null; labels: { free: string; occupied: string }; @@ -346,28 +341,9 @@ function buildRoomOptions(params: { }); } -// Availability request for the selected target slot; no slot, no request. -function requestAvailableClassrooms( - startingDay: string | null | undefined, - startingPeriod: string | null | undefined, - date: string -) { - if (!(startingDay && startingPeriod)) { - return [] as never; - } - - return api.timetable.classrooms.getAvailable.$get({ - query: { - date, - startingDay, - startingPeriod, - }, - }); -} - -type MovedLessonFormValues = InferRequestType< - typeof api.timetable.movedLessons.$post ->['json']; +type MovedLessonFormValues = Parameters< + typeof api.timetable.movedLessons.create +>[0]; const initialState = ( item?: MovedLessonItem | null @@ -519,7 +495,7 @@ function LessonList({ } type FromRoomFieldProps = { - classrooms: Classroom[]; + classrooms: ClassroomItem[]; onChange: (value: string) => void; value: string; }; @@ -648,15 +624,13 @@ export function MovedLessonDialog({ throw new Error('All fields are required for updates'); } await updateMutation.mutateAsync({ + comment: value.comment ?? null, + date: value.date, id: item.movedLesson.id, - payload: { - comment: value.comment ?? null, - date: value.date, - lessonIds: value.lessonIds ?? [], - room: value.room as string, - startingDay: value.startingDay as string, - startingPeriod: value.startingPeriod as string, - }, + lessonIds: value.lessonIds ?? [], + room: value.room as string, + startingDay: value.startingDay as string, + startingPeriod: value.startingPeriod as string, }); } else { await createMutation.mutateAsync(value); @@ -677,17 +651,9 @@ export function MovedLessonDialog({ const formRoom = useStore(form.store, (state) => state.values.room); const periodsQuery = useQuery({ + ...orpc.timetable.periods.getAll.queryOptions({ input: {} }), enabled: mode === 'day', - queryFn: async (): Promise => { - const res = await parseResponse( - api.timetable.periods.getAll.$get({ query: {} }) - ); - if (!res.success) { - throw new Error('Failed to load periods'); - } - return res.data as Period[]; - }, - queryKey: queryKeys.timetable.periods(null), + select: (data): PeriodItem[] => data, }); // The selected lesson's current period (the slot it sits in). @@ -772,22 +738,16 @@ export function MovedLessonDialog({ [dateParam, formStartingDay, formStartingPeriod, item] ); - const availableClassroomsQuery = useApiQuery( - () => - requestAvailableClassrooms( - formStartingDay, - formStartingPeriod, - dateParam - ), - { - enabled: !!formDate && !!formStartingDay && !!formStartingPeriod, - queryKey: queryKeys.timetable.availableClassrooms( - formDate, - formStartingDay, - formStartingPeriod - ), - } - ); + const availableClassroomsQuery = useQuery({ + ...orpc.timetable.classrooms.getAvailable.queryOptions({ + input: { + date: dateParam, + startingDay: formStartingDay ?? '', + startingPeriod: formStartingPeriod ?? '', + }, + }), + enabled: !!formDate && !!formStartingDay && !!formStartingPeriod, + }); // Lessons in the selected source slot (de-duplicated by lesson id). A day // move lists every lesson on the day; a room move narrows to the from-room. diff --git a/apps/iris/src/components/admin/moved-lesson-export.tsx b/apps/iris/src/components/admin/moved-lesson-export.tsx index b0a16c41..b9b9143e 100644 --- a/apps/iris/src/components/admin/moved-lesson-export.tsx +++ b/apps/iris/src/components/admin/moved-lesson-export.tsx @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next'; -import { api } from '@/utils/hc'; +import { api } from '@/utils/orpc'; import { ExportButton, type ExportColumn } from '../export-button'; const columns: ExportColumn[] = [ @@ -21,18 +21,11 @@ export function MovedLessonExportButton({ const { t } = useTranslation(); const fetchCsv = async (): Promise => { - const query: Record = {}; - if (dateRange?.from) { - query.from = dateRange.from.toISOString().slice(0, 10); - } - if (dateRange?.to) { - query.to = dateRange.to.toISOString().slice(0, 10); - } - const res = await api.timetable.movedLessons.export.$get({ query }); - if (!res.ok) { - throw new Error('Export failed'); - } - return res.text(); + const file = await api.timetable.movedLessons.export({ + from: dateRange?.from?.toISOString().slice(0, 10), + to: dateRange?.to?.toISOString().slice(0, 10), + }); + return await file.text(); }; return ( diff --git a/apps/iris/src/components/admin/navigator/navigator-preview.tsx b/apps/iris/src/components/admin/navigator/navigator-preview.tsx index ab71b18c..f42c5ac7 100644 --- a/apps/iris/src/components/admin/navigator/navigator-preview.tsx +++ b/apps/iris/src/components/admin/navigator/navigator-preview.tsx @@ -1,9 +1,13 @@ -import EditorView3D from '@filcdev/navigator-3d/editor-view'; import { Skeleton } from '@filcdev/ui/components/skeleton'; import { cn } from '@filcdev/ui/lib/utils'; import { useTranslation } from 'react-i18next'; +import { Lazy } from '@/components/lazy'; import { useNavigatorGraph } from '@/hooks/navigator'; +// The 3D editor is three.js (~560 kB). It is the bulk of this page, so it +// loads into its own chunk instead of with the route that hosts the preview. +const loadEditorView3D = () => import('@filcdev/navigator-3d/editor-view'); + type NavigatorPreviewProps = { className?: string; }; @@ -24,11 +28,12 @@ export function NavigatorPreview({ className }: NavigatorPreviewProps) { className )} > -
diff --git a/apps/iris/src/components/admin/navigator/transfer-actions.tsx b/apps/iris/src/components/admin/navigator/transfer-actions.tsx index 1ebbeb99..0671d126 100644 --- a/apps/iris/src/components/admin/navigator/transfer-actions.tsx +++ b/apps/iris/src/components/admin/navigator/transfer-actions.tsx @@ -15,7 +15,7 @@ import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import { exportNavigatorJson, - type NavigatorExportPayload, + type NavigatorImportPayload, useImportNavigator, } from '@/hooks/navigator'; @@ -28,7 +28,7 @@ export function TransferActions() { const { t } = useTranslation(); const [exporting, setExporting] = useState(false); const [pendingPayload, setPendingPayload] = - useState(null); + useState(null); const fileInputRef = useRef(null); const importMutation = useImportNavigator({ onSaved: () => { @@ -55,9 +55,9 @@ export function TransferActions() { return; } - let payload: NavigatorExportPayload; + let payload: NavigatorImportPayload; try { - payload = JSON.parse(await file.text()) as NavigatorExportPayload; + payload = JSON.parse(await file.text()) as NavigatorImportPayload; } catch { toast.error(t('navigator.transfer.invalidFile')); return; diff --git a/apps/iris/src/components/admin/news-item-dialog.tsx b/apps/iris/src/components/admin/news-item-dialog.tsx index fb7945fc..4f1bc71d 100644 --- a/apps/iris/src/components/admin/news-item-dialog.tsx +++ b/apps/iris/src/components/admin/news-item-dialog.tsx @@ -91,16 +91,16 @@ export function NewsItemDialog({ if (isAnnouncement) { if (item) { await updateAnnouncement.mutateAsync({ + ...(value as AnnouncementPayload), id: item.id, - payload: value as AnnouncementPayload, }); } else { await createAnnouncement.mutateAsync(value as AnnouncementPayload); } } else if (item) { await updateSystemMessage.mutateAsync({ + ...(value as SystemMessagePayload), id: item.id, - payload: value as SystemMessagePayload, }); } else { await createSystemMessage.mutateAsync(value as SystemMessagePayload); diff --git a/apps/iris/src/components/admin/roles-table.tsx b/apps/iris/src/components/admin/roles-table.tsx index 917a5259..c8687e09 100644 --- a/apps/iris/src/components/admin/roles-table.tsx +++ b/apps/iris/src/components/admin/roles-table.tsx @@ -74,7 +74,7 @@ export function RolesTable({ roles }: RolesTableProps) { + + + + {apiKeysQuery.isLoading && } + {apiKeysQuery.isError && ( + + {t('apiKeys.loadError')} + + )} + {!(apiKeysQuery.isLoading || apiKeysQuery.isError) && ( +
+ + + + {t('apiKeys.name')} + {t('apiKeys.key')} + {t('apiKeys.created')} + {t('apiKeys.expires')} + {t('apiKeys.lastUsed')} + {t('apiKeys.status')} + {t('apiKeys.actions')} + + + + {apiKeys.length === 0 && ( + + + {t('apiKeys.noKeys')} + + + )} + {apiKeys.map((apiKey) => ( + + + {apiKey.name ?? t('apiKeys.unnamed')} + + + {apiKey.start ?? apiKey.prefix ?? t('apiKeys.unknownKey')} + + + {formatTimestamp( + apiKey.createdAt, + i18n.language, + t('apiKeys.unknownDate') + )} + + + {formatTimestamp( + apiKey.expiresAt, + i18n.language, + t('apiKeys.never') + )} + + + {formatTimestamp( + apiKey.lastRequest, + i18n.language, + t('apiKeys.neverUsed') + )} + + + + {t( + apiKey.enabled + ? 'apiKeys.enabled' + : 'apiKeys.disabled' + )} + + + +
+ + + +
+
+
+ ))} +
+
+
+ )} +
+ {isCreateOpen && ( + + )} + {renaming !== null && ( + { + if (!open) { + setRenaming(null); + } + }} + open + /> + )} +
+ ); +} diff --git a/apps/iris/src/components/api-keys/create-api-key-dialog.tsx b/apps/iris/src/components/api-keys/create-api-key-dialog.tsx new file mode 100644 index 00000000..5aeaa2ae --- /dev/null +++ b/apps/iris/src/components/api-keys/create-api-key-dialog.tsx @@ -0,0 +1,194 @@ +import { + Alert, + AlertDescription, + AlertTitle, +} from '@filcdev/ui/components/alert'; +import { Button } from '@filcdev/ui/components/button'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@filcdev/ui/components/dialog'; +import { Field, FieldError, FieldLabel } from '@filcdev/ui/components/field'; +import { Input } from '@filcdev/ui/components/input'; +import { + Select, + SelectTrigger, + SelectValue, +} from '@filcdev/ui/components/select'; +import { useForm } from '@tanstack/react-form'; +import { Copy } from 'lucide-react'; +import { useId, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { BaseDialogProps } from '@/components/admin/admin.types'; +import { useCreateApiKey } from '@/hooks/api-keys'; +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'; +import { + API_KEY_NAME_MAX_LENGTH, + createApiKeySchema, +} from '@/utils/form-schemas'; + +/** + * Expiry choices, as `expiresIn` in SECONDS — the unit better-auth's + * `POST /api-key/create` expects. `never` leaves the field unset, which the + * plugin stores as a null `expiresAt` (no expiry). + */ +const EXPIRY_OPTIONS = [ + { labelKey: 'apiKeys.expiryNever', seconds: undefined, value: 'never' }, + { labelKey: 'apiKeys.expiry30Days', seconds: 2_592_000, value: '30d' }, + { labelKey: 'apiKeys.expiry90Days', seconds: 7_776_000, value: '90d' }, + { labelKey: 'apiKeys.expiry1Year', seconds: 31_536_000, value: '1y' }, +] as const; + +/** + * Create a key, then reveal the raw secret once. + * + * `create` is the only endpoint that ever returns the raw key — every later + * read returns the hash — so the dialog refuses to dismiss itself until the + * user acknowledges the reveal. Otherwise an Escape or a backdrop click would + * discard the only copy of the secret. + */ +export function CreateApiKeyDialog({ open, onOpenChange }: BaseDialogProps) { + const { t } = useTranslation(); + const secretId = useId(); + const copy = useCopyToClipboard(); + const [createdKey, setCreatedKey] = useState(null); + + const createApiKey = useCreateApiKey({ + onCreated: (apiKey) => setCreatedKey(apiKey.key), + }); + + const form = useForm({ + defaultValues: { expiresIn: 'never', name: '' }, + onSubmit: ({ value }) => { + const seconds = EXPIRY_OPTIONS.find( + (option) => option.value === value.expiresIn + )?.seconds; + createApiKey.mutate({ expiresIn: seconds, name: value.name.trim() }); + }, + validators: { onSubmit: createApiKeySchema }, + }); + + const expiryItems = EXPIRY_OPTIONS.map((option) => ({ + label: t(option.labelKey), + value: option.value, + })); + + const handleOpenChange = (next: boolean) => { + if (!next && createdKey !== null) { + return; + } + onOpenChange(next); + }; + + if (createdKey !== null) { + return ( + + + + {t('apiKeys.createdTitle')} + + + {t('apiKeys.revealWarningTitle')} + {t('apiKeys.revealWarning')} + + + {t('apiKeys.key')} +
+ + +
+
+ + + +
+
+ ); + } + + return ( + + + + {t('apiKeys.createTitle')} + +
{ + e.preventDefault(); + form.handleSubmit(); + }} + > + + {(field) => ( + + + {t('apiKeys.nameLabel')} + + field.handleChange(e.target.value)} + placeholder={t('apiKeys.namePlaceholder')} + value={field.state.value} + /> + + + )} + + + {(field) => ( + + + {t('apiKeys.expiryLabel')} + + + + )} + + + + + +
+
+
+ ); +} diff --git a/apps/iris/src/components/api-keys/rename-api-key-dialog.tsx b/apps/iris/src/components/api-keys/rename-api-key-dialog.tsx new file mode 100644 index 00000000..c2b7d4af --- /dev/null +++ b/apps/iris/src/components/api-keys/rename-api-key-dialog.tsx @@ -0,0 +1,88 @@ +import { Button } from '@filcdev/ui/components/button'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@filcdev/ui/components/dialog'; +import { Field, FieldError, FieldLabel } from '@filcdev/ui/components/field'; +import { Input } from '@filcdev/ui/components/input'; +import { useForm } from '@tanstack/react-form'; +import { useTranslation } from 'react-i18next'; +import type { BaseDialogProps } from '@/components/admin/admin.types'; +import { type UserApiKey, useUpdateApiKey } from '@/hooks/api-keys'; +import { + API_KEY_NAME_MAX_LENGTH, + renameApiKeySchema, +} from '@/utils/form-schemas'; + +type RenameApiKeyDialogProps = BaseDialogProps & { + apiKey: UserApiKey; +}; + +/** Rename a key. The secret itself never changes, so it is not shown here. */ +export function RenameApiKeyDialog({ + apiKey, + open, + onOpenChange, +}: RenameApiKeyDialogProps) { + const { t } = useTranslation(); + + const updateApiKey = useUpdateApiKey({ onSaved: () => onOpenChange(false) }); + + const form = useForm({ + defaultValues: { name: apiKey.name ?? '' }, + onSubmit: ({ value }) => { + updateApiKey.mutate({ keyId: apiKey.id, name: value.name.trim() }); + }, + validators: { onSubmit: renameApiKeySchema }, + }); + + return ( + + + + {t('apiKeys.renameTitle')} + +
{ + e.preventDefault(); + form.handleSubmit(); + }} + > + + {(field) => ( + + + {t('apiKeys.nameLabel')} + + field.handleChange(e.target.value)} + placeholder={t('apiKeys.namePlaceholder')} + value={field.state.value} + /> + + + )} + + + + + +
+
+
+ ); +} diff --git a/apps/iris/src/hooks/admin-api-keys.ts b/apps/iris/src/hooks/admin-api-keys.ts new file mode 100644 index 00000000..38104989 --- /dev/null +++ b/apps/iris/src/hooks/admin-api-keys.ts @@ -0,0 +1,92 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; +import { type api, orpc } from '@/utils/orpc'; + +/** + * Admin view over *every* user's API keys. + * + * better-auth's own api-key endpoints are session-scoped — they only ever + * answer for the caller's own keys — so this screen cannot be built on the + * client plugin. It reads the `apikey` table through the admin-only + * `adminApiKeys` procedures instead, which is also why deleting here can + * revoke another user's key. + */ + +/** Options accepted by every mutation hook: react to a successful save. */ +export type MutationCallbacks = { + /** Called after success toast + cache invalidation; use to close dialogs. */ + onSaved?: () => void; +}; + +type AdminApiKeyList = Awaited>; + +/** One API key as the admin table renders it, joined with its owner. */ +export type AdminApiKey = AdminApiKeyList['apiKeys'][number]; + +/** Page size shared by the admin API-keys page and its query. */ +export const ADMIN_API_KEYS_PAGE_SIZE = 20; + +/** Query parameters for the admin API-key list. */ +export type AdminApiKeysInput = Parameters[0]; + +/** Update payload for the admin enable/disable toggle. */ +export type AdminApiKeyUpdateInput = Parameters< + typeof api.adminApiKeys.update +>[0]; + +/** + * React Query options for one page of every user's API keys, exported so a + * route loader can prefetch exactly what the hook reads. + */ +export function adminApiKeysQueryOptions(input: AdminApiKeysInput) { + return orpc.adminApiKeys.list.queryOptions({ input }); +} + +/** Every user's API keys, newest first. */ +export function useAdminApiKeys(input: AdminApiKeysInput) { + return useQuery(adminApiKeysQueryOptions(input)); +} + +/** Kept in sync by every admin key write: the list is the only cached view. */ +function useInvalidateAdminApiKeys() { + const queryClient = useQueryClient(); + return () => + queryClient.invalidateQueries({ queryKey: orpc.adminApiKeys.list.key() }); +} + +/** Enable or disable any user's key. */ +export function useAdminUpdateApiKey({ onSaved }: MutationCallbacks = {}) { + const invalidate = useInvalidateAdminApiKeys(); + const { t } = useTranslation(); + return useMutation( + orpc.adminApiKeys.update.mutationOptions({ + onError: () => { + toast.error(t('adminApiKeys.updateError')); + }, + onSuccess: () => { + toast.success(t('adminApiKeys.updateSuccess')); + invalidate(); + onSaved?.(); + }, + }) + ); +} + +/** Revoke any user's key. */ +export function useAdminDeleteApiKey({ onSaved }: MutationCallbacks = {}) { + const invalidate = useInvalidateAdminApiKeys(); + const { t } = useTranslation(); + return useMutation( + orpc.adminApiKeys.delete.mutationOptions({ + onError: () => { + toast.error(t('adminApiKeys.deleteError')); + }, + onSuccess: () => { + toast.success(t('adminApiKeys.deleteSuccess')); + invalidate(); + onSaved?.(); + }, + }) + ); +} diff --git a/apps/iris/src/hooks/api-keys.ts b/apps/iris/src/hooks/api-keys.ts new file mode 100644 index 00000000..7d81617c --- /dev/null +++ b/apps/iris/src/hooks/api-keys.ts @@ -0,0 +1,126 @@ +import { type ApiKey, type ApiKeyRow, authClient } from '@filcdev/auth/client'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; + +/** One API key as the list endpoint returns it — never the raw secret. */ +export type UserApiKey = ApiKeyRow; + +/** Options accepted by every mutation hook: react to a successful save. */ +export type ApiKeyMutationCallbacks = { + /** Called after success toast + cache invalidation; use to close dialogs. */ + onSaved?: () => void; +}; + +/** `create` also hands back the raw secret, so it takes one more callback. */ +export type CreateApiKeyCallbacks = ApiKeyMutationCallbacks & { + /** Receives the created key, whose `key` field is the raw secret. */ + onCreated?: (apiKey: ApiKey) => void; +}; + +/** + * better-auth answers `{ data, error }` instead of throwing, so a failure would + * otherwise read as a React Query *success*. Every call below routes the two + * halves through this, which turns the error half into a real `Error` — keeping + * the server's message — so `onError` and `isError` behave as usual. + */ +function unwrap( + data: T | null, + error: { message?: string } | null, + fallback: string +): T { + if (data === null) { + throw new Error(error?.message || fallback); + } + return data; +} + +/** + * Every API key owned by `userId`; the query stays idle until a session is + * known. + * + * The endpoints are better-auth's, not oRPC's, so there is no generated query + * key to reuse; scoping the key by user id keeps one user's cache from being + * served to the next after a sign-in. + */ +export function useApiKeys(userId: string | undefined) { + return useQuery({ + enabled: userId !== undefined, + queryFn: async () => { + const result = await authClient.apiKey.list({ query: {} }); + return unwrap(result.data, result.error, 'Failed to load API keys'); + }, + queryKey: ['api-keys', userId], + }); +} + +/** Create a key. The resolved payload carries the raw secret exactly once. */ +export function useCreateApiKey({ + onCreated, + onSaved, +}: CreateApiKeyCallbacks = {}) { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + return useMutation({ + mutationFn: async (input: { expiresIn?: number; name: string }) => { + const result = await authClient.apiKey.create(input); + return unwrap(result.data, result.error, t('apiKeys.createError')); + }, + onError: (error: Error) => { + toast.error(error.message || t('apiKeys.createError')); + }, + onSuccess: (apiKey) => { + toast.success(t('apiKeys.createSuccess')); + queryClient.invalidateQueries({ queryKey: ['api-keys'] }); + // The secret exists only on this response, so it is handed to the caller + // *before* `onSaved` — which is what lets a dialog close on save while + // still having captured the key. + onCreated?.(apiKey); + onSaved?.(); + }, + }); +} + +/** Rename a key or flip its `enabled` flag. */ +export function useUpdateApiKey({ onSaved }: ApiKeyMutationCallbacks = {}) { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + return useMutation({ + mutationFn: async (input: { + enabled?: boolean; + keyId: string; + name?: string; + }) => { + const result = await authClient.apiKey.update(input); + return unwrap(result.data, result.error, t('apiKeys.updateError')); + }, + onError: (error: Error) => { + toast.error(error.message || t('apiKeys.updateError')); + }, + onSuccess: () => { + toast.success(t('apiKeys.updateSuccess')); + queryClient.invalidateQueries({ queryKey: ['api-keys'] }); + onSaved?.(); + }, + }); +} + +/** Revoke a key: the secret becomes unusable immediately and cannot be restored. */ +export function useDeleteApiKey({ onSaved }: ApiKeyMutationCallbacks = {}) { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + return useMutation({ + mutationFn: async (input: { keyId: string }) => { + const result = await authClient.apiKey.delete(input); + return unwrap(result.data, result.error, t('apiKeys.deleteError')); + }, + onError: (error: Error) => { + toast.error(error.message || t('apiKeys.deleteError')); + }, + onSuccess: () => { + toast.success(t('apiKeys.deleteSuccess')); + queryClient.invalidateQueries({ queryKey: ['api-keys'] }); + onSaved?.(); + }, + }); +} diff --git a/apps/iris/src/hooks/use-copy-to-clipboard.ts b/apps/iris/src/hooks/use-copy-to-clipboard.ts new file mode 100644 index 00000000..4c83545a --- /dev/null +++ b/apps/iris/src/hooks/use-copy-to-clipboard.ts @@ -0,0 +1,56 @@ +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; + +/** + * Pre-Clipboard-API copy: the value has to be in a real selection for the + * deprecated command to see it, and the element has to be in the document. + */ +function copyViaSelection(text: string): boolean { + const field = document.createElement('textarea'); + field.value = text; + field.setAttribute('readonly', ''); + field.style.position = 'fixed'; + field.style.opacity = '0'; + document.body.append(field); + field.select(); + try { + return document.execCommand('copy'); + } catch { + return false; + } finally { + field.remove(); + } +} + +/** `navigator.clipboard` is absent over plain HTTP, hence the legacy fallback. */ +async function copyText(text: string): Promise { + if (navigator.clipboard === undefined) { + return copyViaSelection(text); + } + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + return false; + } +} + +/** + * Copying a value the user can never see again (an API key secret) must not + * fail silently, so this hook owns the toast as well as the clipboard write. + */ +export function useCopyToClipboard() { + const { t } = useTranslation(); + + return useCallback( + async (text: string) => { + if (await copyText(text)) { + toast.success(t('apiKeys.copySuccess')); + } else { + toast.error(t('apiKeys.copyError')); + } + }, + [t] + ); +} diff --git a/apps/iris/src/routeTree.gen.ts b/apps/iris/src/routeTree.gen.ts index 5de93456..6423a079 100644 --- a/apps/iris/src/routeTree.gen.ts +++ b/apps/iris/src/routeTree.gen.ts @@ -20,6 +20,7 @@ import { Route as PublicUnsubscribeRouteImport } from './routes/_public/unsubscr import { Route as AuthErrorRouteImport } from './routes/auth/error' import { Route as AuthLoginRouteImport } from './routes/auth/login' import { Route as AuthWelcomeRouteImport } from './routes/auth/welcome' +import { Route as PrivateAdminApiKeysRouteImport } from './routes/_private/admin/api-keys' import { Route as PrivateAdminBugReportsRouteImport } from './routes/_private/admin/bug-reports' import { Route as PrivateAdminKiosksRouteImport } from './routes/_private/admin/kiosks' import { Route as PrivateAdminRolesRouteImport } from './routes/_private/admin/roles' @@ -100,6 +101,11 @@ const AuthWelcomeRoute = AuthWelcomeRouteImport.update({ path: '/auth/welcome', getParentRoute: () => rootRouteImport, } as any) +const PrivateAdminApiKeysRoute = PrivateAdminApiKeysRouteImport.update({ + id: '/api-keys', + path: '/api-keys', + getParentRoute: () => PrivateAdminRouteRoute, +} as any) const PrivateAdminBugReportsRoute = PrivateAdminBugReportsRouteImport.update({ id: '/bug-reports', path: '/bug-reports', @@ -262,6 +268,7 @@ export interface FileRoutesByFullPath { '/auth/error': typeof AuthErrorRoute '/auth/login': typeof AuthLoginRoute '/auth/welcome': typeof AuthWelcomeRoute + '/admin/api-keys': typeof PrivateAdminApiKeysRoute '/admin/bug-reports': typeof PrivateAdminBugReportsRoute '/admin/kiosks': typeof PrivateAdminKiosksRoute '/admin/roles': typeof PrivateAdminRolesRoute @@ -299,6 +306,7 @@ export interface FileRoutesByTo { '/auth/error': typeof AuthErrorRoute '/auth/login': typeof AuthLoginRoute '/auth/welcome': typeof AuthWelcomeRoute + '/admin/api-keys': typeof PrivateAdminApiKeysRoute '/admin/bug-reports': typeof PrivateAdminBugReportsRoute '/admin/kiosks': typeof PrivateAdminKiosksRoute '/admin/roles': typeof PrivateAdminRolesRoute @@ -339,6 +347,7 @@ export interface FileRoutesById { '/auth/login': typeof AuthLoginRoute '/auth/welcome': typeof AuthWelcomeRoute '/_public/': typeof PublicIndexRoute + '/_private/admin/api-keys': typeof PrivateAdminApiKeysRoute '/_private/admin/bug-reports': typeof PrivateAdminBugReportsRoute '/_private/admin/kiosks': typeof PrivateAdminKiosksRoute '/_private/admin/roles': typeof PrivateAdminRolesRoute @@ -378,6 +387,7 @@ export interface FileRouteTypes { | '/auth/error' | '/auth/login' | '/auth/welcome' + | '/admin/api-keys' | '/admin/bug-reports' | '/admin/kiosks' | '/admin/roles' @@ -415,6 +425,7 @@ export interface FileRouteTypes { | '/auth/error' | '/auth/login' | '/auth/welcome' + | '/admin/api-keys' | '/admin/bug-reports' | '/admin/kiosks' | '/admin/roles' @@ -454,6 +465,7 @@ export interface FileRouteTypes { | '/auth/login' | '/auth/welcome' | '/_public/' + | '/_private/admin/api-keys' | '/_private/admin/bug-reports' | '/_private/admin/kiosks' | '/_private/admin/roles' @@ -569,6 +581,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthWelcomeRouteImport parentRoute: typeof rootRouteImport } + '/_private/admin/api-keys': { + id: '/_private/admin/api-keys' + path: '/api-keys' + fullPath: '/admin/api-keys' + preLoaderRoute: typeof PrivateAdminApiKeysRouteImport + parentRoute: typeof PrivateAdminRouteRoute + } '/_private/admin/bug-reports': { id: '/_private/admin/bug-reports' path: '/bug-reports' @@ -755,6 +774,7 @@ declare module '@tanstack/react-router' { } interface PrivateAdminRouteRouteChildren { + PrivateAdminApiKeysRoute: typeof PrivateAdminApiKeysRoute PrivateAdminBugReportsRoute: typeof PrivateAdminBugReportsRoute PrivateAdminKiosksRoute: typeof PrivateAdminKiosksRoute PrivateAdminRolesRoute: typeof PrivateAdminRolesRoute @@ -783,6 +803,7 @@ interface PrivateAdminRouteRouteChildren { } const PrivateAdminRouteRouteChildren: PrivateAdminRouteRouteChildren = { + PrivateAdminApiKeysRoute: PrivateAdminApiKeysRoute, PrivateAdminBugReportsRoute: PrivateAdminBugReportsRoute, PrivateAdminKiosksRoute: PrivateAdminKiosksRoute, PrivateAdminRolesRoute: PrivateAdminRolesRoute, diff --git a/apps/iris/src/routes/_private/admin/api-keys.tsx b/apps/iris/src/routes/_private/admin/api-keys.tsx new file mode 100644 index 00000000..eb122501 --- /dev/null +++ b/apps/iris/src/routes/_private/admin/api-keys.tsx @@ -0,0 +1,194 @@ +import { permissions } from '@filcdev/api/permissions'; +import { Button } from '@filcdev/ui/components/button'; +import { Input } from '@filcdev/ui/components/input'; +import { createFileRoute, useNavigate } from '@tanstack/react-router'; +import { CalendarClock, CircleCheck, KeyRound, RefreshCw } from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { z } from 'zod'; +import { AdminApiKeysTable } from '@/components/admin/api-keys-table'; +import { StatCard } from '@/components/admin/stat-card'; +import { PermissionGuard } from '@/components/util/permission-guard'; +import { QueryBoundary } from '@/components/util/query-boundary'; +import { + ADMIN_API_KEYS_PAGE_SIZE, + adminApiKeysQueryOptions, + useAdminApiKeys, +} from '@/hooks/admin-api-keys'; +import { prefetch } from '@/utils/orpc'; + +/** Keys expiring within this window count as "expiring soon". */ +const EXPIRING_SOON_DAYS = 30; + +const searchSchema = z.object({ + ownerId: z.string().optional(), + page: z.coerce.number().int().min(1).default(1), + search: z.string().default(''), +}); + +export const Route = createFileRoute('/_private/admin/api-keys')({ + component: AdminApiKeysPage, + // Params come off the location rather than `loaderDeps`, matching the admin + // users page: a `loaderDeps` above `validateSearch` would fix the search type. + loader: ({ context, location }) => { + const params = new URLSearchParams(location.searchStr); + const page = Number(params.get('page')) || 1; + const ownerId = params.get('ownerId') ?? undefined; + return prefetch( + context.queryClient, + adminApiKeysQueryOptions({ + limit: ADMIN_API_KEYS_PAGE_SIZE, + offset: (page - 1) * ADMIN_API_KEYS_PAGE_SIZE, + ownerId, + search: params.get('search') ?? '', + }) + ); + }, + validateSearch: searchSchema, +}); + +function AdminApiKeysPage() { + return ( + + + + ); +} + +function AdminApiKeysContent() { + const { t } = useTranslation(); + const { page, ownerId, search } = Route.useSearch(); + const navigate = useNavigate({ from: Route.fullPath }); + const [inputValue, setInputValue] = useState(search); + + useEffect(() => { + setInputValue(search); + }, [search]); + + useEffect(() => { + if (inputValue === search) { + return; + } + const timer = setTimeout(() => { + navigate({ + search: (prev) => ({ ...prev, page: 1, search: inputValue }), + }); + }, 500); + return () => clearTimeout(timer); + }, [inputValue, search, navigate]); + + const apiKeysQuery = useAdminApiKeys({ + limit: ADMIN_API_KEYS_PAGE_SIZE, + offset: (page - 1) * ADMIN_API_KEYS_PAGE_SIZE, + ownerId, + search, + }); + + const total = apiKeysQuery.data?.total ?? 0; + const totalPages = Math.max(1, Math.ceil(total / ADMIN_API_KEYS_PAGE_SIZE)); + + // The cards describe the loaded page; `total` is the only whole-set count the + // endpoint returns. + const stats = useMemo(() => { + const rows = apiKeysQuery.data?.apiKeys ?? []; + const soonThreshold = Date.now() + EXPIRING_SOON_DAYS * 24 * 60 * 60 * 1000; + return { + enabled: rows.filter((apiKey) => apiKey.enabled).length, + expiringSoon: rows.filter((apiKey) => { + if (apiKey.expiresAt === null || apiKey.expiresAt === undefined) { + return false; + } + const expiresAt = new Date(apiKey.expiresAt).getTime(); + return ( + !Number.isNaN(expiresAt) && + expiresAt > Date.now() && + expiresAt <= soonThreshold + ); + }).length, + }; + }, [apiKeysQuery.data]); + + return ( +
+
+

+ {t('adminApiKeys.title')} +

+

{t('adminApiKeys.description')}

+
+ +
+ setInputValue(e.target.value)} + placeholder={t('adminApiKeys.searchPlaceholder')} + type="text" + value={inputValue} + /> +
+ +
+
+ +
+ } + label={t('adminApiKeys.totalKeys')} + value={total} + /> + } + label={t('adminApiKeys.enabledOnPage')} + value={stats.enabled} + /> + } + label={t('adminApiKeys.expiringSoonOnPage')} + value={stats.expiringSoon} + /> +
+ + + {(data) => ( +
+ +
+
+ {t('adminApiKeys.currentPage')} {page} / {totalPages} +
+
+ + +
+
+
+ )} +
+
+ ); +} diff --git a/apps/iris/src/routes/_private/settings.tsx b/apps/iris/src/routes/_private/settings.tsx index 489286aa..b66eccab 100644 --- a/apps/iris/src/routes/_private/settings.tsx +++ b/apps/iris/src/routes/_private/settings.tsx @@ -19,6 +19,7 @@ import { createFileRoute } from '@tanstack/react-router'; import { useTheme } from 'next-themes'; import { useEffect, useId, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { ApiKeysCard } from '@/components/api-keys/api-keys-card'; import { useNotificationSettings, useUpdateNotificationSettings, @@ -222,6 +223,8 @@ function SettingsPage() { {saveMutation.isPending && } {t('common.accept')} + + ); } diff --git a/apps/iris/src/utils/form-schemas.ts b/apps/iris/src/utils/form-schemas.ts index 51330864..1cf00071 100644 --- a/apps/iris/src/utils/form-schemas.ts +++ b/apps/iris/src/utils/form-schemas.ts @@ -78,6 +78,26 @@ export const otaUpdateSchema = z.object({ url: z.string().trim().min(1, 'Firmware URL is required'), }); +/** better-auth's api-key plugin rejects names outside 1-32 characters. */ +export const API_KEY_NAME_MAX_LENGTH = 32; + +export const apiKeyNameSchema = z + .string() + .trim() + .min(1, 'Name is required') + .max(API_KEY_NAME_MAX_LENGTH, 'Name is too long'); + +/** Creating a key needs a name and one of the expiry choices. */ +export const createApiKeySchema = z.object({ + expiresIn: z.string(), + name: apiKeyNameSchema, +}); + +/** Renaming only touches the name. */ +export const renameApiKeySchema = z.object({ + name: apiKeyNameSchema, +}); + export const timetableEditSchema = z.object({ name: z.string(), validFrom: z.date().optional(), From 643eee235d5c24a4071bb68467f582c63c65b4fe Mon Sep 17 00:00:00 2001 From: Tamas Vince Date: Thu, 8 Oct 2026 09:15:58 +0200 Subject: [PATCH 19/32] feat(iris): show Empty component when no available timetable --- apps/iris/public/locales/en/translation.json | 2 ++ apps/iris/public/locales/hu/translation.json | 2 ++ apps/iris/src/components/timetable/index.tsx | 24 ++++++++++++++++++-- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/apps/iris/public/locales/en/translation.json b/apps/iris/public/locales/en/translation.json index 220cd0ae..106f3851 100644 --- a/apps/iris/public/locales/en/translation.json +++ b/apps/iris/public/locales/en/translation.json @@ -219,6 +219,8 @@ "noLessonsOnThisDay": "No lessons", "emptyWeekTitle": "No lessons this week", "emptyWeekDescription": "There are no lessons for this selection.", + "noValidTimetableTitle": "No timetable available", + "noValidTimetableDescription": "There is no current timetable, so classes cannot be shown right now. Please check back later.", "selectGroupPlaceholder": "Pick your group", "selectGroupSuccess": "Group updated", "selectGroupError": "Failed to update group", diff --git a/apps/iris/public/locales/hu/translation.json b/apps/iris/public/locales/hu/translation.json index 82a1b664..752f3bd9 100644 --- a/apps/iris/public/locales/hu/translation.json +++ b/apps/iris/public/locales/hu/translation.json @@ -211,6 +211,8 @@ "noLessonsOnThisDay": "Nincs óra", "emptyWeekTitle": "Nincs óra ezen a héten", "emptyWeekDescription": "Ehhez a válogatáshoz nincs óra.", + "noValidTimetableTitle": "Nincs elérhető órarend", + "noValidTimetableDescription": "Jelenleg nincs érvényes órarend, ezért az órák most nem jeleníthetők meg. Kérjük, nézz vissza később.", "selectGroupPlaceholder": "Válaszd ki a csoportod", "selectGroupSuccess": "Csoport frissítve", "selectGroupError": "Nem sikerült frissíteni a csoportot", diff --git a/apps/iris/src/components/timetable/index.tsx b/apps/iris/src/components/timetable/index.tsx index 79b58435..2c5db27c 100644 --- a/apps/iris/src/components/timetable/index.tsx +++ b/apps/iris/src/components/timetable/index.tsx @@ -1,10 +1,11 @@ import { useSession } from '@filcdev/auth/client'; import { Empty } from '@filcdev/ui/components/empty'; import { Skeleton } from '@filcdev/ui/components/skeleton'; +import { isDefinedError } from '@orpc/client'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useNavigate } from '@tanstack/react-router'; import dayjs from 'dayjs'; -import { CalendarX } from 'lucide-react'; +import { CalendarOff, CalendarX } from 'lucide-react'; import type { Dispatch, SetStateAction } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -624,6 +625,19 @@ export function TimetableView() { const selectorLoading = getSelectorLoading(); + /** + * No current timetable: the backend answers `latestValid` with NOT_FOUND when + * nothing is valid today. Without this branch the page would sit on the + * skeletons below forever, because `isLoading` stays true while no selection + * can ever be resolved. An explicit selection (a `?timetable=` param naming a + * still-loadable timetable) is left alone. + */ + const hasNoValidTimetable = + selectedTimetableId === null && + (latestValidTimetableQuery.data === null || + (isDefinedError(latestValidTimetableQuery.error) && + latestValidTimetableQuery.error.code === 'NOT_FOUND')); + const isLoading = selectorLoading || lessonsQuery.isLoading || !activeSelectionId; const hasError = @@ -693,7 +707,13 @@ export function TimetableView() { )} - {isLoading ? ( + {hasNoValidTimetable ? ( + } + title={t('timetable.noValidTimetableTitle')} + /> + ) : isLoading ? (
From 6ca7075f3280aac532d880134db259cdac80bf59 Mon Sep 17 00:00:00 2001 From: Tamas Vince Date: Thu, 8 Oct 2026 10:10:00 +0200 Subject: [PATCH 20/32] fix(navigator): fix graph api preview returning 500 --- apps/chronos/openapi/chronos-openapi.json | 6 +++--- .../_private/admin/navigator/classroom-types.tsx | 4 +++- .../api/src/domains/navigator/classroom-type.ts | 14 ++++++++++++-- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/apps/chronos/openapi/chronos-openapi.json b/apps/chronos/openapi/chronos-openapi.json index 3a32943c..a9bc6b52 100644 --- a/apps/chronos/openapi/chronos-openapi.json +++ b/apps/chronos/openapi/chronos-openapi.json @@ -13718,7 +13718,7 @@ "properties": { "colorhex": { "type": "string", - "pattern": "^#[0-9a-fA-F]{6}$" + "pattern": "^#[0-9a-fA-F]{6}(?:[0-9a-fA-F]{2})?$" }, "name": { "type": "string", @@ -14498,7 +14498,7 @@ "properties": { "colorhex": { "type": "string", - "pattern": "^#[0-9a-fA-F]{6}$" + "pattern": "^#[0-9a-fA-F]{6}(?:[0-9a-fA-F]{2})?$" }, "name": { "type": "string", @@ -16261,7 +16261,7 @@ "properties": { "colorhex": { "type": "string", - "pattern": "^#[0-9a-fA-F]{6}$" + "pattern": "^#[0-9a-fA-F]{6}(?:[0-9a-fA-F]{2})?$" }, "id": { "type": "string" diff --git a/apps/iris/src/routes/_private/admin/navigator/classroom-types.tsx b/apps/iris/src/routes/_private/admin/navigator/classroom-types.tsx index 3a8b3f13..8f4a20c3 100644 --- a/apps/iris/src/routes/_private/admin/navigator/classroom-types.tsx +++ b/apps/iris/src/routes/_private/admin/navigator/classroom-types.tsx @@ -48,7 +48,9 @@ export const Route = createFileRoute( ]), }); -const COLOR_HEX_REGEX = /^#[0-9a-fA-F]{6}$/; +// `#RRGGBBAA` mirrors the API schema: imported rows carry an alpha pair, and +// the 3D palette reads the leading six digits. +const COLOR_HEX_REGEX = /^#[0-9a-fA-F]{6}(?:[0-9a-fA-F]{2})?$/; type ClassroomTypeDialogProps = BaseDialogProps & { classroomType: ClassroomType | null; diff --git a/packages/api/src/domains/navigator/classroom-type.ts b/packages/api/src/domains/navigator/classroom-type.ts index c8cf80f5..9a180eaf 100644 --- a/packages/api/src/domains/navigator/classroom-type.ts +++ b/packages/api/src/domains/navigator/classroom-type.ts @@ -1,10 +1,20 @@ import z from 'zod'; -/** A classroom category; `colorhex` is the fill colour used in the 3D views. */ +/** + * A classroom category; `colorhex` is the fill colour used in the 3D views. + * + * `#RRGGBBAA` is accepted alongside `#RRGGBB`: the upstream campus capture (and + * so the imported rows) carries an alpha pair, and the 3D palette reads the + * leading six digits (`packages/navigator-3d/src/kiosk/palette.ts`), so both + * forms describe the same colour. + */ export const classroomTypeSchema = z.object({ colorhex: z .string() - .regex(/^#[0-9a-fA-F]{6}$/, 'Colour must be a hex value like #1a2b3c'), + .regex( + /^#[0-9a-fA-F]{6}(?:[0-9a-fA-F]{2})?$/, + 'Colour must be a hex value like #1a2b3c or #1a2b3cff' + ), id: z.string(), name: z.string().min(1), }); From 681edc04a8cbd9ff01d5bc85d1dd9132e1cf0b28 Mon Sep 17 00:00:00 2001 From: Tamas Vince Date: Thu, 8 Oct 2026 11:33:54 +0200 Subject: [PATCH 21/32] feat: add petrikwifi integration --- apps/chronos/.env.example | 27 + apps/chronos/openapi/chronos-openapi.json | 8527 +++++++++++++++++ apps/chronos/scripts/migrate-petrikwifi.ts | 187 + .../migrations/0024_moaning_marvel_boy.sql | 84 + .../migrations/meta/0024_snapshot.json | 4538 +++++++++ .../database/migrations/meta/_journal.json | 7 + apps/chronos/src/database/scripts/reset.ts | 2 + apps/chronos/src/middleware/wifi.ts | 15 + apps/chronos/src/modules/index.ts | 2 + apps/chronos/src/modules/schemas.ts | 2 + apps/chronos/src/modules/wifi/_module.ts | 26 + apps/chronos/src/modules/wifi/_router.ts | 77 + apps/chronos/src/modules/wifi/admin.ts | 560 ++ apps/chronos/src/modules/wifi/radius.ts | 43 + apps/chronos/src/modules/wifi/schema.ts | 149 + apps/chronos/src/modules/wifi/self.ts | 324 + apps/chronos/src/modules/wifi/stats.ts | 51 + apps/chronos/src/modules/wifi/status.ts | 7 + .../chronos/src/modules/wifi/utils/cleanup.ts | 25 + .../src/modules/wifi/utils/constants.ts | 3 + .../src/modules/wifi/utils/controller.ts | 64 + .../src/modules/wifi/utils/encryptor.ts | 146 + apps/chronos/src/modules/wifi/utils/mac.ts | 18 + apps/chronos/src/modules/wifi/utils/radius.ts | 328 + .../src/modules/wifi/utils/speed-profile.ts | 104 + apps/chronos/src/modules/wifi/utils/sync.ts | 80 + .../src/modules/wifi/utils/unifi-api.ts | 320 + .../modules/wifi/utils/unifi-controller.ts | 125 + apps/chronos/src/router.ts | 2 + apps/chronos/src/utils/authentication.ts | 25 +- apps/chronos/src/utils/authorization.ts | 6 + apps/chronos/src/utils/environment.ts | 12 + apps/iris/public/locales/en/translation.json | 236 +- apps/iris/public/locales/hu/translation.json | 236 +- apps/iris/src/components/admin/sidebar.tsx | 39 +- apps/iris/src/components/navbar.tsx | 67 +- .../src/components/util/relative-time.tsx | 48 + .../src/components/wifi/wifi-auth-chart.tsx | 101 + .../iris/src/components/wifi/wifi-dialogs.tsx | 753 ++ .../iris/src/components/wifi/wifi-filters.tsx | 201 + apps/iris/src/hooks/wifi-admin.ts | 367 + apps/iris/src/hooks/wifi.ts | 126 + apps/iris/src/routeTree.gen.ts | 106 + .../src/routes/_private/admin/wifi/index.tsx | 340 + .../src/routes/_private/admin/wifi/nas.tsx | 178 + .../_private/admin/wifi/speed-profiles.tsx | 366 + .../src/routes/_private/admin/wifi/users.tsx | 636 ++ apps/iris/src/routes/_private/route.tsx | 12 +- apps/iris/src/routes/_private/wifi.tsx | 765 ++ apps/iris/src/routes/auth/welcome.tsx | 52 +- apps/iris/src/utils/form-schemas.ts | 68 + apps/radius/.env.example | 8 + apps/radius/certs/.gitignore | 10 + apps/radius/certs/Makefile | 186 + apps/radius/certs/ca.crl | Bin 0 -> 468 bytes apps/radius/certs/ca.der | Bin 0 -> 1251 bytes apps/radius/certs/ca.pem | 29 + apps/radius/certs/passwords.example.mk | 6 + apps/radius/docker-compose.yml | 37 + apps/radius/freeradius/Dockerfile | 5 + apps/radius/freeradius/clients.conf | 13 + apps/radius/freeradius/default | 70 + apps/radius/freeradius/docker-entrypoint.sh | 51 + apps/radius/freeradius/eap | 66 + apps/radius/freeradius/eapol_test.conf | 10 + apps/radius/freeradius/inner-eap | 20 + apps/radius/freeradius/inner-tunnel | 67 + apps/radius/freeradius/rest | 59 + docs/freeradius-setup.md | 32 + packages/api/src/contract/index.ts | 2 + packages/api/src/contract/wifi.ts | 525 + packages/api/src/domains/wifi/admin.ts | 187 + packages/api/src/domains/wifi/radius.ts | 26 + packages/api/src/domains/wifi/self.ts | 63 + packages/api/src/domains/wifi/stats.ts | 16 + packages/api/src/domains/wifi/status.ts | 8 + packages/api/src/permissions.ts | 2 + packages/ui/src/components/password-input.tsx | 57 + 78 files changed, 22106 insertions(+), 32 deletions(-) create mode 100644 apps/chronos/scripts/migrate-petrikwifi.ts create mode 100644 apps/chronos/src/database/migrations/0024_moaning_marvel_boy.sql create mode 100644 apps/chronos/src/database/migrations/meta/0024_snapshot.json create mode 100644 apps/chronos/src/middleware/wifi.ts create mode 100644 apps/chronos/src/modules/wifi/_module.ts create mode 100644 apps/chronos/src/modules/wifi/_router.ts create mode 100644 apps/chronos/src/modules/wifi/admin.ts create mode 100644 apps/chronos/src/modules/wifi/radius.ts create mode 100644 apps/chronos/src/modules/wifi/schema.ts create mode 100644 apps/chronos/src/modules/wifi/self.ts create mode 100644 apps/chronos/src/modules/wifi/stats.ts create mode 100644 apps/chronos/src/modules/wifi/status.ts create mode 100644 apps/chronos/src/modules/wifi/utils/cleanup.ts create mode 100644 apps/chronos/src/modules/wifi/utils/constants.ts create mode 100644 apps/chronos/src/modules/wifi/utils/controller.ts create mode 100644 apps/chronos/src/modules/wifi/utils/encryptor.ts create mode 100644 apps/chronos/src/modules/wifi/utils/mac.ts create mode 100644 apps/chronos/src/modules/wifi/utils/radius.ts create mode 100644 apps/chronos/src/modules/wifi/utils/speed-profile.ts create mode 100644 apps/chronos/src/modules/wifi/utils/sync.ts create mode 100644 apps/chronos/src/modules/wifi/utils/unifi-api.ts create mode 100644 apps/chronos/src/modules/wifi/utils/unifi-controller.ts create mode 100644 apps/iris/src/components/util/relative-time.tsx create mode 100644 apps/iris/src/components/wifi/wifi-auth-chart.tsx create mode 100644 apps/iris/src/components/wifi/wifi-dialogs.tsx create mode 100644 apps/iris/src/components/wifi/wifi-filters.tsx create mode 100644 apps/iris/src/hooks/wifi-admin.ts create mode 100644 apps/iris/src/hooks/wifi.ts create mode 100644 apps/iris/src/routes/_private/admin/wifi/index.tsx create mode 100644 apps/iris/src/routes/_private/admin/wifi/nas.tsx create mode 100644 apps/iris/src/routes/_private/admin/wifi/speed-profiles.tsx create mode 100644 apps/iris/src/routes/_private/admin/wifi/users.tsx create mode 100644 apps/iris/src/routes/_private/wifi.tsx create mode 100644 apps/radius/.env.example create mode 100644 apps/radius/certs/.gitignore create mode 100644 apps/radius/certs/Makefile create mode 100644 apps/radius/certs/ca.crl create mode 100644 apps/radius/certs/ca.der create mode 100644 apps/radius/certs/ca.pem create mode 100644 apps/radius/certs/passwords.example.mk create mode 100644 apps/radius/docker-compose.yml create mode 100644 apps/radius/freeradius/Dockerfile create mode 100644 apps/radius/freeradius/clients.conf create mode 100644 apps/radius/freeradius/default create mode 100644 apps/radius/freeradius/docker-entrypoint.sh create mode 100644 apps/radius/freeradius/eap create mode 100644 apps/radius/freeradius/eapol_test.conf create mode 100644 apps/radius/freeradius/inner-eap create mode 100644 apps/radius/freeradius/inner-tunnel create mode 100644 apps/radius/freeradius/rest create mode 100644 docs/freeradius-setup.md create mode 100644 packages/api/src/contract/wifi.ts create mode 100644 packages/api/src/domains/wifi/admin.ts create mode 100644 packages/api/src/domains/wifi/radius.ts create mode 100644 packages/api/src/domains/wifi/self.ts create mode 100644 packages/api/src/domains/wifi/stats.ts create mode 100644 packages/api/src/domains/wifi/status.ts create mode 100644 packages/ui/src/components/password-input.tsx diff --git a/apps/chronos/.env.example b/apps/chronos/.env.example index 33b06d25..b6f9be1e 100644 --- a/apps/chronos/.env.example +++ b/apps/chronos/.env.example @@ -111,6 +111,33 @@ CHRONOS_ENTRA_CLIENT_SECRET=FILL_ME_IN # talks to the API by absolute URL, so its own origin belongs here. # CHRONOS_TRUSTED_ORIGINS=https://kiosk.filc.petrik.hu +# -------------------------------------------------- +# | Wi-Fi / FreeRADIUS (optional) | +# -------------------------------------------------- +# CHRONOS_WIFI_ENABLED=true +# CHRONOS_WIFI_SSID="Filc WiFi" +# The RADIUS endpoint fails closed unless the shared secret and source IP are configured. +# CHRONOS_FREERADIUS_IP=172.18.1.2 +# CHRONOS_FREERADIUS_SHARED_SECRET=FILL_ME_IN +# CHRONOS_WIFI_ENCRYPTION_SECRET=FILL_ME_IN +# Relative or absolute path to ca.pem or the directory containing it (e.g. ../radius/certs/ca.pem or ../radius/certs) +# CHRONOS_WIFI_CA_CERT_PATH=../radius/certs/ca.pem +# CHRONOS_WIFI_CONTROLLER_PROVIDER=none + +# Set the provider to unifi to enable controller enrichment and speed profiles. +# UNIFI_HOST=unifi.example.com + +# Disable TLS certificate verification for the UniFi controller requests only. +# It's recommended to use a valid TLS certificate instead of disabling +# verification, or trusting the self-signed certificate in your system's trust +# store. This is only needed if you are using a self-signed certificate on your +# Unifi controller. +# UNIFI_INSECURE_TLS=false + +# UNIFI_PORT=8443 +# UNIFI_USERNAME= +# UNIFI_PASSWORD= + # -------------------------------------------------- # | Kiosk public data (optional) | # -------------------------------------------------- diff --git a/apps/chronos/openapi/chronos-openapi.json b/apps/chronos/openapi/chronos-openapi.json index a9bc6b52..7d3a8655 100644 --- a/apps/chronos/openapi/chronos-openapi.json +++ b/apps/chronos/openapi/chronos-openapi.json @@ -44708,6 +44708,8533 @@ } ] } + }, + "/wifi/auth-logs": { + "get": { + "operationId": "getWifiAuthLogs", + "description": "List WiFi auth logs.", + "tags": [ + "WiFi" + ], + "parameters": [ + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 10000, + "default": 10000 + }, + "allowEmptyValue": true, + "allowReserved": true + }, + { + "name": "offset", + "in": "query", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "default": 0 + }, + "allowEmptyValue": true, + "allowReserved": true + }, + { + "name": "search", + "in": "query", + "schema": { + "type": "string" + }, + "allowEmptyValue": true, + "allowReserved": true + }, + { + "name": "failureReason", + "in": "query", + "schema": { + "type": "string" + }, + "allowEmptyValue": true, + "allowReserved": true + }, + { + "name": "result", + "in": "query", + "schema": { + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "allowEmptyValue": true, + "allowReserved": true + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "logs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "deviceNickname": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "deviceReportedHostname": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "failureReason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "number" + }, + "macAddress": { + "type": "string" + }, + "nasComment": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nasIpAddress": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nasMacAddress": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "result": { + "type": "boolean" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "userComment": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "username": { + "type": "string" + }, + "wifiUserId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "deviceNickname", + "deviceReportedHostname", + "failureReason", + "id", + "macAddress", + "nasComment", + "nasIpAddress", + "nasMacAddress", + "result", + "timestamp", + "userComment", + "username", + "wifiUserId" + ] + } + } + }, + "required": [ + "logs" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "WiFi", + "x-filc_type_name": "@unit WifiAuthLogListResponse", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/wifi/devices": { + "post": { + "operationId": "postWifiDevices", + "description": "Create a WiFi device or global MAC ban.", + "tags": [ + "WiFi" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "adminNotes": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "banned": { + "type": "boolean" + }, + "macAddress": { + "type": "string", + "pattern": "^([0-9a-fA-F]{2}[:-]){5}([0-9a-fA-F]{2})$" + }, + "nickname": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "wifiUserId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "macAddress" + ] + } + } + } + }, + "responses": { + "201": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "device": { + "type": "object", + "properties": { + "adminNotes": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "banned": { + "type": "boolean" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "lastActiveAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + { + "type": "null" + } + ] + }, + "macAddress": { + "type": "string", + "pattern": "^([0-9a-fA-F]{2}[:-]){5}([0-9a-fA-F]{2})$" + }, + "nickname": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "reportedHostname": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "wifiUserId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "adminNotes", + "banned", + "createdAt", + "id", + "lastActiveAt", + "macAddress", + "nickname", + "reportedHostname", + "updatedAt", + "wifiUserId" + ] + } + }, + "required": [ + "device" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "WiFi", + "x-filc_type_name": "@unit WifiDeviceResponse", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + }, + "get": { + "operationId": "getWifiDevices", + "description": "List WiFi devices and global MAC bans.", + "tags": [ + "WiFi" + ], + "parameters": [ + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 10000, + "default": 10000 + }, + "allowEmptyValue": true, + "allowReserved": true + }, + { + "name": "offset", + "in": "query", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "default": 0 + }, + "allowEmptyValue": true, + "allowReserved": true + }, + { + "name": "search", + "in": "query", + "schema": { + "type": "string" + }, + "allowEmptyValue": true, + "allowReserved": true + }, + { + "name": "wifiUserId", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "const": "" + }, + { + "const": "null" + }, + { + "type": "null" + } + ] + }, + "allowEmptyValue": true, + "allowReserved": true + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "devices": { + "type": "array", + "items": { + "type": "object", + "properties": { + "adminNotes": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "banned": { + "type": "boolean" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "lastActiveAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + { + "type": "null" + } + ] + }, + "macAddress": { + "type": "string", + "pattern": "^([0-9a-fA-F]{2}[:-]){5}([0-9a-fA-F]{2})$" + }, + "nickname": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "reportedHostname": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "wifiUserId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "adminNotes", + "banned", + "createdAt", + "id", + "lastActiveAt", + "macAddress", + "nickname", + "reportedHostname", + "updatedAt", + "wifiUserId" + ] + } + } + }, + "required": [ + "devices" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "WiFi", + "x-filc_type_name": "@unit WifiDeviceListResponse", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/wifi/devices/{id}": { + "delete": { + "operationId": "deleteWifiDevicesById", + "description": "Delete a WiFi device or global MAC ban.", + "tags": [ + "WiFi" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "id" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "WiFi", + "x-filc_type_name": "@nodata", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + }, + "put": { + "operationId": "putWifiDevicesById", + "description": "Update a WiFi device or global MAC ban.", + "tags": [ + "WiFi" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "adminNotes": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "banned": { + "type": "boolean" + }, + "macAddress": { + "type": "string", + "pattern": "^([0-9a-fA-F]{2}[:-]){5}([0-9a-fA-F]{2})$" + }, + "nickname": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "wifiUserId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "device": { + "type": "object", + "properties": { + "adminNotes": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "banned": { + "type": "boolean" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "lastActiveAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + { + "type": "null" + } + ] + }, + "macAddress": { + "type": "string", + "pattern": "^([0-9a-fA-F]{2}[:-]){5}([0-9a-fA-F]{2})$" + }, + "nickname": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "reportedHostname": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "wifiUserId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "adminNotes", + "banned", + "createdAt", + "id", + "lastActiveAt", + "macAddress", + "nickname", + "reportedHostname", + "updatedAt", + "wifiUserId" + ] + } + }, + "required": [ + "device" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "WiFi", + "x-filc_type_name": "@unit WifiDeviceResponse", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/wifi/nas": { + "post": { + "operationId": "postWifiNas", + "description": "Register a WiFi NAS device.", + "tags": [ + "WiFi" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "comment": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "ipAddress": { + "type": "string" + }, + "macAddress": { + "type": "string", + "pattern": "^([0-9a-fA-F]{2}[:-]){5}([0-9a-fA-F]{2})$" + } + }, + "required": [ + "ipAddress", + "macAddress" + ] + } + } + } + }, + "responses": { + "201": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "nas": { + "type": "object", + "properties": { + "comment": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "ipAddress": { + "type": "string" + }, + "macAddress": { + "type": "string", + "pattern": "^([0-9a-fA-F]{2}[:-]){5}([0-9a-fA-F]{2})$" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "comment", + "createdAt", + "id", + "ipAddress", + "macAddress", + "updatedAt" + ] + } + }, + "required": [ + "nas" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "WiFi", + "x-filc_type_name": "@unit WifiNasResponse", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + }, + "get": { + "operationId": "getWifiNas", + "description": "List authorized WiFi NAS devices.", + "tags": [ + "WiFi" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "nas": { + "type": "array", + "items": { + "type": "object", + "properties": { + "comment": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "ipAddress": { + "type": "string" + }, + "macAddress": { + "type": "string", + "pattern": "^([0-9a-fA-F]{2}[:-]){5}([0-9a-fA-F]{2})$" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "comment", + "createdAt", + "id", + "ipAddress", + "macAddress", + "updatedAt" + ] + } + } + }, + "required": [ + "nas" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "WiFi", + "x-filc_type_name": "@unit WifiNasListResponse", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/wifi/nas/{id}": { + "delete": { + "operationId": "deleteWifiNasById", + "description": "Delete a WiFi NAS device.", + "tags": [ + "WiFi" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "id" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "WiFi", + "x-filc_type_name": "@nodata", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + }, + "put": { + "operationId": "putWifiNasById", + "description": "Update a WiFi NAS device.", + "tags": [ + "WiFi" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "comment": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "ipAddress": { + "type": "string" + }, + "macAddress": { + "type": "string", + "pattern": "^([0-9a-fA-F]{2}[:-]){5}([0-9a-fA-F]{2})$" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "nas": { + "type": "object", + "properties": { + "comment": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "ipAddress": { + "type": "string" + }, + "macAddress": { + "type": "string", + "pattern": "^([0-9a-fA-F]{2}[:-]){5}([0-9a-fA-F]{2})$" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "comment", + "createdAt", + "id", + "ipAddress", + "macAddress", + "updatedAt" + ] + } + }, + "required": [ + "nas" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "WiFi", + "x-filc_type_name": "@unit WifiNasResponse", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/wifi/radius/authorize": { + "post": { + "operationId": "postWifiRadiusAuthorize", + "description": "Authorize a WiFi client for FreeRADIUS.", + "tags": [ + "WiFi" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "destination": { + "type": "string" + }, + "IP": { + "type": "string" + }, + "NAS": { + "type": "string" + }, + "password": { + "type": "string" + }, + "sharedSecret": { + "type": "string" + }, + "source": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "source", + "username" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "Cleartext-Password": { + "type": "string" + }, + "control:Cleartext-Password": { + "type": "string" + }, + "Session-Timeout": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "Cleartext-Password", + "control:Cleartext-Password", + "Session-Timeout" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "WiFi", + "x-filc_type_name": "@unit WifiRadiusAuthorizeResponse" + } + }, + "/wifi/role-speed-profiles": { + "post": { + "operationId": "postWifiRoleSpeedProfiles", + "description": "Create a WiFi role speed-profile mapping.", + "tags": [ + "WiFi" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "priority": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "default": 0 + }, + "roleName": { + "type": "string", + "minLength": 1 + }, + "speedProfileId": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "roleName", + "speedProfileId" + ] + } + } + } + }, + "responses": { + "201": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "mapping": { + "type": "object", + "properties": { + "priority": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "roleName": { + "type": "string" + }, + "speedProfileId": { + "type": "string" + } + }, + "required": [ + "priority", + "roleName", + "speedProfileId" + ] + } + }, + "required": [ + "mapping" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "WiFi", + "x-filc_type_name": "@unit WifiRoleSpeedProfileResponse", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + }, + "get": { + "operationId": "getWifiRoleSpeedProfiles", + "description": "List WiFi role speed-profile mappings.", + "tags": [ + "WiFi" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "roleSpeedProfiles": { + "type": "array", + "items": { + "type": "object", + "properties": { + "priority": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "roleName": { + "type": "string" + }, + "speedProfileId": { + "type": "string" + } + }, + "required": [ + "priority", + "roleName", + "speedProfileId" + ] + } + } + }, + "required": [ + "roleSpeedProfiles" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "WiFi", + "x-filc_type_name": "@unit WifiRoleSpeedProfileListResponse", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/wifi/role-speed-profiles/{id}": { + "delete": { + "operationId": "deleteWifiRoleSpeedProfilesById", + "description": "Delete a WiFi role speed-profile mapping.", + "tags": [ + "WiFi" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "WiFi", + "x-filc_type_name": "@nodata", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + }, + "put": { + "operationId": "putWifiRoleSpeedProfilesById", + "description": "Update a WiFi role speed-profile mapping.", + "tags": [ + "WiFi" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + } + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "priority": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "default": 0 + }, + "roleName": { + "type": "string", + "minLength": 1 + }, + "speedProfileId": { + "type": "string", + "minLength": 1 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "mapping": { + "type": "object", + "properties": { + "priority": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "roleName": { + "type": "string" + }, + "speedProfileId": { + "type": "string" + } + }, + "required": [ + "priority", + "roleName", + "speedProfileId" + ] + } + }, + "required": [ + "mapping" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "WiFi", + "x-filc_type_name": "@unit WifiRoleSpeedProfileResponse", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/wifi/self/certificate": { + "get": { + "operationId": "getWifiSelfCertificate", + "description": "Download the CA certificate for the authenticated user's WiFi network.", + "tags": [ + "WiFi" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "string", + "contentMediaType": "*/*" + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "WiFi", + "x-filc_type_name": "@nodata", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/wifi/self/password": { + "put": { + "operationId": "putWifiSelfPassword", + "description": "Change the authenticated user's WiFi account password.", + "tags": [ + "WiFi" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "newPassword": { + "type": "string", + "minLength": 8, + "maxLength": 256 + } + }, + "required": [ + "newPassword" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "WiFi", + "x-filc_type_name": "@nodata", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/wifi/self": { + "post": { + "operationId": "postWifiSelf", + "description": "Create a new WiFi account for the authenticated user.", + "tags": [ + "WiFi" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "password": { + "type": "string", + "minLength": 8, + "maxLength": 256 + } + }, + "required": [ + "password" + ] + } + } + } + }, + "responses": { + "201": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "wifi": { + "type": "object", + "properties": { + "banned": { + "type": "boolean" + }, + "devices": { + "type": "array", + "items": { + "type": "object", + "properties": { + "banned": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "lastActiveAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + { + "type": "null" + } + ] + }, + "macAddress": { + "type": "string" + }, + "nickname": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "reportedHostname": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "banned", + "id", + "lastActiveAt", + "macAddress", + "nickname", + "reportedHostname" + ] + } + }, + "speedLimit": { + "type": "object", + "properties": { + "downloadSpeedMbps": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "roleName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "source": { + "enum": [ + "override", + "role", + "wlan_default" + ], + "type": "string" + }, + "speedProfileId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "uploadSpeedMbps": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "downloadSpeedMbps", + "roleName", + "source", + "speedProfileId", + "uploadSpeedMbps" + ] + }, + "username": { + "type": "string" + } + }, + "required": [ + "banned", + "devices", + "speedLimit", + "username" + ] + } + }, + "required": [ + "wifi" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "WiFi", + "x-filc_type_name": "@unit WifiSelfCreateResponse @field(.wifi, WifiSelf)", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + }, + "get": { + "operationId": "getWifiSelf", + "description": "Get the authenticated user's WiFi account and devices.", + "tags": [ + "WiFi" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "wifi": { + "type": "object", + "properties": { + "banned": { + "type": "boolean" + }, + "devices": { + "type": "array", + "items": { + "type": "object", + "properties": { + "banned": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "lastActiveAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + { + "type": "null" + } + ] + }, + "macAddress": { + "type": "string" + }, + "nickname": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "reportedHostname": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "banned", + "id", + "lastActiveAt", + "macAddress", + "nickname", + "reportedHostname" + ] + } + }, + "speedLimit": { + "type": "object", + "properties": { + "downloadSpeedMbps": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "roleName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "source": { + "enum": [ + "override", + "role", + "wlan_default" + ], + "type": "string" + }, + "speedProfileId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "uploadSpeedMbps": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "downloadSpeedMbps", + "roleName", + "source", + "speedProfileId", + "uploadSpeedMbps" + ] + }, + "username": { + "type": "string" + } + }, + "required": [ + "banned", + "devices", + "speedLimit", + "username" + ] + } + }, + "required": [ + "wifi" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "WiFi", + "x-filc_type_name": "@unit WifiSelfResponse @field(.wifi, WifiSelf)", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/wifi/self/devices/{id}": { + "put": { + "operationId": "putWifiSelfDevicesById", + "description": "Rename one of the authenticated user's WiFi devices.", + "tags": [ + "WiFi" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "nickname": { + "anyOf": [ + { + "type": "string", + "maxLength": 100 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "nickname" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "device": { + "type": "object", + "properties": { + "banned": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "lastActiveAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + { + "type": "null" + } + ] + }, + "macAddress": { + "type": "string" + }, + "nickname": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "reportedHostname": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "banned", + "id", + "lastActiveAt", + "macAddress", + "nickname", + "reportedHostname" + ] + } + }, + "required": [ + "device" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "WiFi", + "x-filc_type_name": "@unit WifiSelfDeviceResponse @field(.device, WifiSelfDevice)", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/wifi/speed-profiles": { + "post": { + "operationId": "postWifiSpeedProfiles", + "description": "Create a UniFi WiFi speed profile.", + "tags": [ + "WiFi" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "downloadSpeedMbps": { + "type": "integer", + "minimum": -1, + "maximum": 9007199254740991, + "default": -1 + }, + "name": { + "type": "string", + "minLength": 1 + }, + "uploadSpeedMbps": { + "type": "integer", + "minimum": -1, + "maximum": 9007199254740991, + "default": -1 + } + }, + "required": [ + "name" + ] + } + } + } + }, + "responses": { + "201": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "profile": { + "type": "object", + "properties": { + "downloadSpeedMbps": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "isWlanDefault": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "uploadSpeedMbps": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "downloadSpeedMbps", + "id", + "isWlanDefault", + "name", + "uploadSpeedMbps" + ] + } + }, + "required": [ + "profile" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "WiFi", + "x-filc_type_name": "@unit WifiSpeedProfileResponse", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + }, + "get": { + "operationId": "getWifiSpeedProfiles", + "description": "List UniFi WiFi speed profiles.", + "tags": [ + "WiFi" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "speedProfiles": { + "type": "array", + "items": { + "type": "object", + "properties": { + "downloadSpeedMbps": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "isWlanDefault": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "uploadSpeedMbps": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "downloadSpeedMbps", + "id", + "isWlanDefault", + "name", + "uploadSpeedMbps" + ] + } + } + }, + "required": [ + "speedProfiles" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "WiFi", + "x-filc_type_name": "@unit WifiSpeedProfileListResponse", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/wifi/speed-profiles/{id}": { + "delete": { + "operationId": "deleteWifiSpeedProfilesById", + "description": "Delete a UniFi WiFi speed profile.", + "tags": [ + "WiFi" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "WiFi", + "x-filc_type_name": "@nodata", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + }, + "put": { + "operationId": "putWifiSpeedProfilesById", + "description": "Update a UniFi WiFi speed profile.", + "tags": [ + "WiFi" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + } + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "downloadSpeedMbps": { + "type": "integer", + "minimum": -1, + "maximum": 9007199254740991, + "default": -1 + }, + "name": { + "type": "string", + "minLength": 1 + }, + "uploadSpeedMbps": { + "type": "integer", + "minimum": -1, + "maximum": 9007199254740991, + "default": -1 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "profile": { + "type": "object", + "properties": { + "downloadSpeedMbps": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "isWlanDefault": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "uploadSpeedMbps": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "downloadSpeedMbps", + "id", + "isWlanDefault", + "name", + "uploadSpeedMbps" + ] + } + }, + "required": [ + "profile" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "WiFi", + "x-filc_type_name": "@unit WifiSpeedProfileResponse", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/wifi/stats/overview": { + "get": { + "operationId": "getWifiStatsOverview", + "description": "Get WiFi account, device, active-device, and auth statistics.", + "tags": [ + "WiFi" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "activeDevices": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "authSeries": { + "type": "array", + "items": { + "type": "object", + "properties": { + "accepted": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "date": { + "type": "string" + }, + "rejected": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "accepted", + "date", + "rejected" + ] + } + }, + "totalDevices": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "totalUsers": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "activeDevices", + "authSeries", + "totalDevices", + "totalUsers" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "WiFi", + "x-filc_type_name": "@unit WifiStatsResponse @field(.stats, WifiStats)", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/wifi/status": { + "get": { + "operationId": "getWifiStatus", + "description": "Get the WiFi module status and configured SSID.", + "tags": [ + "WiFi" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "ssid": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "enabled", + "ssid" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "WiFi", + "x-filc_type_name": "@unit WifiStatusResponse" + } + }, + "/wifi/users": { + "post": { + "operationId": "postWifiUsers", + "description": "Create a WiFi user account.", + "tags": [ + "WiFi" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "allowedMacAddresses": { + "type": "array", + "items": { + "type": "string", + "pattern": "^([0-9a-fA-F]{2}[:-]){5}([0-9a-fA-F]{2})$" + } + }, + "comment": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "password": { + "type": "string", + "minLength": 8, + "maxLength": 256 + }, + "speedProfileId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "userId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + }, + "username": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "password", + "username" + ] + } + } + } + }, + "responses": { + "201": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "user": { + "type": "object", + "properties": { + "allowedMacAddresses": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string", + "pattern": "^([0-9a-fA-F]{2}[:-]){5}([0-9a-fA-F]{2})$" + } + }, + { + "type": "null" + } + ] + }, + "banned": { + "type": "boolean" + }, + "comment": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "createdBy": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + }, + "creatorName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string", + "format": "uuid" + }, + "lastActiveAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + { + "type": "null" + } + ] + }, + "speedProfileId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "userId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + }, + "username": { + "type": "string" + } + }, + "required": [ + "allowedMacAddresses", + "banned", + "comment", + "createdAt", + "createdBy", + "creatorName", + "id", + "lastActiveAt", + "speedProfileId", + "updatedAt", + "userId", + "username" + ] + } + }, + "required": [ + "user" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "WiFi", + "x-filc_type_name": "@unit WifiUserResponse", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + }, + "get": { + "operationId": "getWifiUsers", + "description": "List WiFi user accounts.", + "tags": [ + "WiFi" + ], + "parameters": [ + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 10000, + "default": 10000 + }, + "allowEmptyValue": true, + "allowReserved": true + }, + { + "name": "offset", + "in": "query", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "default": 0 + }, + "allowEmptyValue": true, + "allowReserved": true + }, + { + "name": "search", + "in": "query", + "schema": { + "type": "string" + }, + "allowEmptyValue": true, + "allowReserved": true + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "users": { + "type": "array", + "items": { + "type": "object", + "properties": { + "allowedMacAddresses": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string", + "pattern": "^([0-9a-fA-F]{2}[:-]){5}([0-9a-fA-F]{2})$" + } + }, + { + "type": "null" + } + ] + }, + "banned": { + "type": "boolean" + }, + "comment": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "createdBy": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + }, + "creatorName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string", + "format": "uuid" + }, + "lastActiveAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + { + "type": "null" + } + ] + }, + "speedProfileId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "userId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + }, + "username": { + "type": "string" + } + }, + "required": [ + "allowedMacAddresses", + "banned", + "comment", + "createdAt", + "createdBy", + "creatorName", + "id", + "lastActiveAt", + "speedProfileId", + "updatedAt", + "userId", + "username" + ] + } + } + }, + "required": [ + "users" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "WiFi", + "x-filc_type_name": "@unit WifiUserListResponse", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/wifi/users/{id}": { + "delete": { + "operationId": "deleteWifiUsersById", + "description": "Delete a WiFi user account.", + "tags": [ + "WiFi" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "id" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "WiFi", + "x-filc_type_name": "@nodata", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + }, + "put": { + "operationId": "putWifiUsersById", + "description": "Update a WiFi user account.", + "tags": [ + "WiFi" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "allowedMacAddresses": { + "type": "array", + "items": { + "type": "string", + "pattern": "^([0-9a-fA-F]{2}[:-]){5}([0-9a-fA-F]{2})$" + } + }, + "comment": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "password": { + "anyOf": [ + { + "type": "string", + "minLength": 8, + "maxLength": 256 + }, + { + "const": "" + } + ] + }, + "speedProfileId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "userId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + }, + "username": { + "type": "string", + "minLength": 1 + }, + "banned": { + "type": "boolean" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "user": { + "type": "object", + "properties": { + "allowedMacAddresses": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string", + "pattern": "^([0-9a-fA-F]{2}[:-]){5}([0-9a-fA-F]{2})$" + } + }, + { + "type": "null" + } + ] + }, + "banned": { + "type": "boolean" + }, + "comment": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "createdBy": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + }, + "creatorName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string", + "format": "uuid" + }, + "lastActiveAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + { + "type": "null" + } + ] + }, + "speedProfileId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "userId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + }, + "username": { + "type": "string" + } + }, + "required": [ + "allowedMacAddresses", + "banned", + "comment", + "createdAt", + "createdBy", + "creatorName", + "id", + "lastActiveAt", + "speedProfileId", + "updatedAt", + "userId", + "username" + ] + } + }, + "required": [ + "user" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "x-filc_group_name": "WiFi", + "x-filc_type_name": "@unit WifiUserResponse", + "x-filc_auth": "true", + "security": [ + { + "sessionAuth": [] + } + ] + } } } } diff --git a/apps/chronos/scripts/migrate-petrikwifi.ts b/apps/chronos/scripts/migrate-petrikwifi.ts new file mode 100644 index 00000000..dd84177a --- /dev/null +++ b/apps/chronos/scripts/migrate-petrikwifi.ts @@ -0,0 +1,187 @@ +import { Database } from 'bun:sqlite'; +import process from 'node:process'; +import { getLogger } from '@logtape/logtape'; +import type { db as database } from '../src/database'; +import { db } from '../src/database'; +import { user as systemUser } from '../src/database/schema/authentication'; +import { wifiDevice, wifiNas, wifiUser } from '../src/modules/wifi/schema'; +import { canonicalizeMac } from '../src/modules/wifi/utils/mac'; +import { configureLogger } from '../src/utils/logger'; + +await configureLogger('chronos'); + +const logger = getLogger(['chronos', 'migrate-petrikwifi']); + +type LegacyUser = { + allowedDevices: string | null; + banned: number | null; + comment: string | null; + password: string; + salt: string; + userID: number; + username: string; +}; + +type LegacyDevice = { + banned: number | null; + comment: string | null; + device: string; + lastActive: string | null; + userID: number | null; +}; + +type LegacyAp = { + AP: string; + IP: string; + comment: string | null; +}; + +type Transaction = Parameters[0]>[0]; + +type UserLookups = { + /** Chronos user id by their full email address. */ + emailToId: Map; + /** Chronos user id by their email's local part, for legacy rows that used it. */ + usernameToId: Map; +}; + +const readLegacy = (sqlite: Database) => ({ + aps: sqlite.query('SELECT * FROM APs').all() as LegacyAp[], + devices: sqlite.query('SELECT * FROM Devices').all() as LegacyDevice[], + users: sqlite.query('SELECT * FROM Users').all() as LegacyUser[], +}); + +const buildLookups = async (): Promise => { + const existingUsers = await db + .select({ email: systemUser.email, id: systemUser.id }) + .from(systemUser); + + return { + emailToId: new Map( + existingUsers.map((user) => [user.email.toLowerCase(), user.id]) + ), + usernameToId: new Map( + existingUsers.map((user) => [ + (user.email.split('@')[0] || '').toLowerCase(), + user.id, + ]) + ), + }; +}; + +/** Insert the legacy accounts; returns legacy id → new wifi account id. */ +const importUsers = async ( + tx: Transaction, + users: LegacyUser[], + lookups: UserLookups +): Promise> => { + const userIdMap = new Map(); + + for (const user of users) { + const wifiUsername = (user.username || '').toLowerCase(); + const userId = + lookups.emailToId.get(wifiUsername) ?? + lookups.usernameToId.get(wifiUsername) ?? + null; + + const [inserted] = await tx + .insert(wifiUser) + .values({ + allowedMacAddresses: user.allowedDevices + ? (JSON.parse(user.allowedDevices) as string[]) + : [], + banned: Boolean(user.banned), + comment: user.comment, + createdBy: userId, + encryptedPassword: user.password, + salt: user.salt, + userId, + username: user.username, + }) + .returning({ id: wifiUser.id }); + + if (inserted) { + userIdMap.set(user.userID, inserted.id); + } + } + + return userIdMap; +}; + +const importDevices = async ( + tx: Transaction, + devices: LegacyDevice[], + userIdMap: Map +): Promise => { + for (const device of devices) { + const wifiUserId = userIdMap.get(device.userID ?? -1); + // Unassigned devices are only worth carrying over when they are bans. + if (!(wifiUserId || device.banned)) { + continue; + } + + await tx + .insert(wifiDevice) + .values({ + banned: Boolean(device.banned), + lastActiveAt: device.lastActive ? new Date(device.lastActive) : null, + macAddress: canonicalizeMac(device.device), + nickname: device.comment, + wifiUserId: wifiUserId ?? null, + }) + .onConflictDoNothing({ target: wifiDevice.macAddress }); + } +}; + +const importNas = async (tx: Transaction, aps: LegacyAp[]): Promise => { + for (const ap of aps) { + await tx + .insert(wifiNas) + .values({ + comment: ap.comment, + ipAddress: ap.IP, + macAddress: canonicalizeMac(ap.AP), + }) + .onConflictDoNothing({ target: wifiNas.macAddress }); + } +}; + +/** + * One-shot import of a PetrikWiFi SQLite database into the Chronos wifi tables. + * + * Every write runs in one transaction: the legacy `wifi_user` username is + * unique here too, so a failure part-way would leave rows that make a re-run + * fail on that constraint instead of completing. + * + * Duplicate devices/NAS entries are skipped with `onConflictDoNothing`, not by + * catching the constraint error: Postgres aborts the whole transaction on a + * failed statement, so catching inside it would fail everything after. + */ +async function main() { + const sqliteDbPath = process.env.PETRIKWIFI_DB_PATH; + if (!sqliteDbPath) { + logger.error('PETRIKWIFI_DB_PATH is not set.'); + process.exit(1); + } + + const { aps, devices, users } = readLegacy(new Database(sqliteDbPath)); + const lookups = await buildLookups(); + + await db.transaction(async (tx) => { + const userIdMap = await importUsers(tx, users, lookups); + await importDevices(tx, devices, userIdMap); + await importNas(tx, aps); + }); + + logger.info('Imported {users} users, {devices} devices and {nas} NAS rows', { + devices: devices.length, + nas: aps.length, + users: users.length, + }); + process.exit(0); +} + +main().catch((error) => { + logger.error('PetrikWiFi migration failed: {error}', { error }); + process.exitCode = 1; +}); diff --git a/apps/chronos/src/database/migrations/0024_moaning_marvel_boy.sql b/apps/chronos/src/database/migrations/0024_moaning_marvel_boy.sql new file mode 100644 index 00000000..0ad70cbd --- /dev/null +++ b/apps/chronos/src/database/migrations/0024_moaning_marvel_boy.sql @@ -0,0 +1,84 @@ +CREATE TABLE "wifi_auth_log" ( + "failure_reason" text, + "id" integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "wifi_auth_log_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), + "mac_address" text NOT NULL, + "nas_ip_address" text, + "nas_mac_address" text, + "result" boolean NOT NULL, + "timestamp" timestamp DEFAULT now() NOT NULL, + "username" text NOT NULL, + "wifi_user_id" uuid +); +--> statement-breakpoint +CREATE TABLE "wifi_device" ( + "admin_notes" text, + "banned" boolean DEFAULT false NOT NULL, + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "last_active_at" timestamp, + "mac_address" text NOT NULL, + "nickname" text, + "reported_hostname" text, + "wifi_user_id" uuid, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "wifi_device_mac_address_unique" UNIQUE("mac_address") +); +--> statement-breakpoint +CREATE TABLE "wifi_nas" ( + "comment" text, + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "ip_address" text NOT NULL, + "mac_address" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "wifi_nas_mac_address_unique" UNIQUE("mac_address") +); +--> statement-breakpoint +CREATE TABLE "wifi_role_speed_profile" ( + "priority" integer DEFAULT 0 NOT NULL, + "role_name" text PRIMARY KEY NOT NULL, + "speed_profile_id" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "wifi_speed_profile" ( + "download_speed_mbps" integer, + "id" text PRIMARY KEY NOT NULL, + "is_wlan_default" boolean DEFAULT false NOT NULL, + "name" text NOT NULL, + "synced_at" timestamp DEFAULT now() NOT NULL, + "upload_speed_mbps" integer +); +--> statement-breakpoint +CREATE TABLE "wifi_user" ( + "allowed_mac_addresses" text[], + "banned" boolean DEFAULT false NOT NULL, + "comment" text, + "created_by" uuid, + "encrypted_password" text NOT NULL, + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "salt" text NOT NULL, + "speed_profile_id" text, + "user_id" uuid, + "username" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "wifi_user_username_unique" UNIQUE("username") +); +--> statement-breakpoint +ALTER TABLE "wifi_auth_log" ADD CONSTRAINT "wifi_auth_log_wifi_user_id_wifi_user_id_fk" FOREIGN KEY ("wifi_user_id") REFERENCES "public"."wifi_user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "wifi_device" ADD CONSTRAINT "wifi_device_wifi_user_id_wifi_user_id_fk" FOREIGN KEY ("wifi_user_id") REFERENCES "public"."wifi_user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "wifi_role_speed_profile" ADD CONSTRAINT "wifi_role_speed_profile_speed_profile_id_wifi_speed_profile_id_fk" FOREIGN KEY ("speed_profile_id") REFERENCES "public"."wifi_speed_profile"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "wifi_user" ADD CONSTRAINT "wifi_user_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "wifi_user" ADD CONSTRAINT "wifi_user_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "wifi_auth_log_timestamp_idx" ON "wifi_auth_log" USING btree ("timestamp");--> statement-breakpoint +CREATE INDEX "wifi_auth_log_username_idx" ON "wifi_auth_log" USING btree ("username");--> statement-breakpoint +CREATE INDEX "wifi_auth_log_mac_address_idx" ON "wifi_auth_log" USING btree ("mac_address");--> statement-breakpoint +CREATE UNIQUE INDEX "wifi_device_mac_address_idx" ON "wifi_device" USING btree ("mac_address");--> statement-breakpoint +CREATE INDEX "wifi_device_wifi_user_id_idx" ON "wifi_device" USING btree ("wifi_user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "wifi_nas_mac_address_idx" ON "wifi_nas" USING btree ("mac_address");--> statement-breakpoint +CREATE UNIQUE INDEX "wifi_nas_ip_address_idx" ON "wifi_nas" USING btree ("ip_address");--> statement-breakpoint +CREATE INDEX "wifi_role_speed_profile_role_name_idx" ON "wifi_role_speed_profile" USING btree ("role_name");--> statement-breakpoint +CREATE UNIQUE INDEX "wifi_user_username_idx" ON "wifi_user" USING btree ("username");--> statement-breakpoint +CREATE INDEX "wifi_user_user_id_idx" ON "wifi_user" USING btree ("user_id"); \ No newline at end of file diff --git a/apps/chronos/src/database/migrations/meta/0024_snapshot.json b/apps/chronos/src/database/migrations/meta/0024_snapshot.json new file mode 100644 index 00000000..8ef8542f --- /dev/null +++ b/apps/chronos/src/database/migrations/meta/0024_snapshot.json @@ -0,0 +1,4538 @@ +{ + "id": "eba14589-f75e-4524-8be0-4005795c69e6", + "prevId": "1169f8b0-5106-4a1b-99c9-1b7e703e0250", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.apikey": { + "name": "apikey", + "schema": "", + "columns": { + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "apikey_config_id_idx": { + "name": "apikey_config_id_idx", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikey_key_idx": { + "name": "apikey_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikey_reference_id_idx": { + "name": "apikey_reference_id_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "cohort_id": { + "name": "cohort_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "nickname": { + "name": "nickname", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "roles": { + "name": "roles", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{\"user\"}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.role": { + "name": "role", + "schema": "", + "columns": { + "can": { + "name": "can", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "role_name_unique": { + "name": "role_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bug_report": { + "name": "bug_report", + "schema": "", + "columns": { + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "page": { + "name": "page", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reporter_email": { + "name": "reporter_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reporter_id": { + "name": "reporter_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "bug_report_status_idx": { + "name": "bug_report_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bug_report_reporter_id_user_id_fk": { + "name": "bug_report_reporter_id_user_id_fk", + "tableFrom": "bug_report", + "tableTo": "user", + "columnsFrom": [ + "reporter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "button_pressed": { + "name": "button_pressed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "card_data": { + "name": "card_data", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "card_id": { + "name": "card_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "device_id": { + "name": "device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "audit_log_card_id_card_id_fk": { + "name": "audit_log_card_id_card_id_fk", + "tableFrom": "audit_log", + "tableTo": "card", + "columnsFrom": [ + "card_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_device_id_device_id_fk": { + "name": "audit_log_device_id_device_id_fk", + "tableFrom": "audit_log", + "tableTo": "device", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_log_user_id_user_id_fk": { + "name": "audit_log_user_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.card": { + "name": "card", + "schema": "", + "columns": { + "card_uid": { + "name": "card_uid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "frozen": { + "name": "frozen", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "card_user_id_user_id_fk": { + "name": "card_user_id_user_id_fk", + "tableFrom": "card", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "card_card_uid_unique": { + "name": "card_card_uid_unique", + "nullsNotDistinct": false, + "columns": [ + "card_uid" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.card_device": { + "name": "card_device", + "schema": "", + "columns": { + "card_id": { + "name": "card_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "card_device_card_id_idx": { + "name": "card_device_card_id_idx", + "columns": [ + { + "expression": "card_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "card_device_device_id_idx": { + "name": "card_device_device_id_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "card_device_card_id_card_id_fk": { + "name": "card_device_card_id_card_id_fk", + "tableFrom": "card_device", + "tableTo": "card", + "columnsFrom": [ + "card_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "card_device_device_id_device_id_fk": { + "name": "card_device_device_id_device_id_fk", + "tableFrom": "card_device", + "tableTo": "device", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "card_device_card_id_device_id_pk": { + "name": "card_device_card_id_device_id_pk", + "columns": [ + "card_id", + "device_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device": { + "name": "device", + "schema": "", + "columns": { + "api_token": { + "name": "api_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "last_reset_reason": { + "name": "last_reset_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "device_api_token_unique": { + "name": "device_api_token_unique", + "nullsNotDistinct": false, + "columns": [ + "api_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_health": { + "name": "device_health", + "schema": "", + "columns": { + "device_id": { + "name": "device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "device_meta": { + "name": "device_meta", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "device_health_device_id_device_id_fk": { + "name": "device_health_device_id_device_id_fk", + "tableFrom": "device_health", + "tableTo": "device", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiosk": { + "name": "kiosk", + "schema": "", + "columns": { + "app_version": { + "name": "app_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_seen_ip": { + "name": "last_seen_ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "machine_id": { + "name": "machine_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "kiosk_machine_id_unique": { + "name": "kiosk_machine_id_unique", + "nullsNotDistinct": false, + "columns": [ + "machine_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.navigator_corridor": { + "name": "navigator_corridor", + "schema": "", + "columns": { + "barrier_free": { + "name": "barrier_free", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "building_id": { + "name": "building_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "is_outdoor": { + "name": "is_outdoor", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storey": { + "name": "storey", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "x1": { + "name": "x1", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "x2": { + "name": "x2", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "y1": { + "name": "y1", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "y2": { + "name": "y2", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "navigator_corridor_building_id_idx": { + "name": "navigator_corridor_building_id_idx", + "columns": [ + { + "expression": "building_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "navigator_corridor_building_id_building_id_fk": { + "name": "navigator_corridor_building_id_building_id_fk", + "tableFrom": "navigator_corridor", + "tableTo": "building", + "columnsFrom": [ + "building_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.navigator_lift": { + "name": "navigator_lift", + "schema": "", + "columns": { + "building_id": { + "name": "building_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "max_storey": { + "name": "max_storey", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "min_storey": { + "name": "min_storey", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "x": { + "name": "x", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "y": { + "name": "y", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "navigator_lift_building_id_idx": { + "name": "navigator_lift_building_id_idx", + "columns": [ + { + "expression": "building_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "navigator_lift_building_id_building_id_fk": { + "name": "navigator_lift_building_id_building_id_fk", + "tableFrom": "navigator_lift", + "tableTo": "building", + "columnsFrom": [ + "building_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.navigator_stair": { + "name": "navigator_stair", + "schema": "", + "columns": { + "building_id": { + "name": "building_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "max_storey": { + "name": "max_storey", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "min_storey": { + "name": "min_storey", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rotation": { + "name": "rotation", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "x": { + "name": "x", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "y": { + "name": "y", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "navigator_stair_building_id_idx": { + "name": "navigator_stair_building_id_idx", + "columns": [ + { + "expression": "building_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "navigator_stair_building_id_building_id_fk": { + "name": "navigator_stair_building_id_building_id_fk", + "tableFrom": "navigator_stair", + "tableTo": "building", + "columnsFrom": [ + "building_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.navigator_translation": { + "name": "navigator_translation", + "schema": "", + "columns": { + "lang_key": { + "name": "lang_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text_key": { + "name": "text_key", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "navigator_translation_lang_key_text_key_pk": { + "name": "navigator_translation_lang_key_text_key_pk", + "columns": [ + "lang_key", + "text_key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.announcement": { + "name": "announcement", + "schema": "", + "columns": { + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "highlighted": { + "name": "highlighted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "image_byte_size": { + "name": "image_byte_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "image_content_type": { + "name": "image_content_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_key": { + "name": "image_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_updated_at": { + "name": "image_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "kiosk_only": { + "name": "kiosk_only", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "valid_from": { + "name": "valid_from", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "valid_until": { + "name": "valid_until", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "announcement_author_id_user_id_fk": { + "name": "announcement_author_id_user_id_fk", + "tableFrom": "announcement", + "tableTo": "user", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "announcement_image_complete": { + "name": "announcement_image_complete", + "value": "(\"announcement\".\"image_key\" IS NULL) = (\"announcement\".\"image_content_type\" IS NULL)\n AND (\"announcement\".\"image_key\" IS NULL) = (\"announcement\".\"image_byte_size\" IS NULL)\n AND (\"announcement\".\"image_key\" IS NULL) = (\"announcement\".\"image_updated_at\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.announcement_cohort_mtm": { + "name": "announcement_cohort_mtm", + "schema": "", + "columns": { + "announcement_id": { + "name": "announcement_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cohort_id": { + "name": "cohort_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "announcement_cohort_mtm_announcement_id_idx": { + "name": "announcement_cohort_mtm_announcement_id_idx", + "columns": [ + { + "expression": "announcement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "announcement_cohort_mtm_cohort_id_idx": { + "name": "announcement_cohort_mtm_cohort_id_idx", + "columns": [ + { + "expression": "cohort_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "announcement_cohort_mtm_announcement_id_announcement_id_fk": { + "name": "announcement_cohort_mtm_announcement_id_announcement_id_fk", + "tableFrom": "announcement_cohort_mtm", + "tableTo": "announcement", + "columnsFrom": [ + "announcement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "announcement_cohort_mtm_cohort_id_cohort_id_fk": { + "name": "announcement_cohort_mtm_cohort_id_cohort_id_fk", + "tableFrom": "announcement_cohort_mtm", + "tableTo": "cohort", + "columnsFrom": [ + "cohort_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "announcement_cohort_mtm_announcement_id_cohort_id_pk": { + "name": "announcement_cohort_mtm_announcement_id_cohort_id_pk", + "columns": [ + "announcement_id", + "cohort_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.announcement_kiosk_mtm": { + "name": "announcement_kiosk_mtm", + "schema": "", + "columns": { + "announcement_id": { + "name": "announcement_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kiosk_id": { + "name": "kiosk_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "announcement_kiosk_mtm_announcement_id_idx": { + "name": "announcement_kiosk_mtm_announcement_id_idx", + "columns": [ + { + "expression": "announcement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "announcement_kiosk_mtm_kiosk_id_idx": { + "name": "announcement_kiosk_mtm_kiosk_id_idx", + "columns": [ + { + "expression": "kiosk_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "announcement_kiosk_mtm_announcement_id_announcement_id_fk": { + "name": "announcement_kiosk_mtm_announcement_id_announcement_id_fk", + "tableFrom": "announcement_kiosk_mtm", + "tableTo": "announcement", + "columnsFrom": [ + "announcement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "announcement_kiosk_mtm_kiosk_id_kiosk_id_fk": { + "name": "announcement_kiosk_mtm_kiosk_id_kiosk_id_fk", + "tableFrom": "announcement_kiosk_mtm", + "tableTo": "kiosk", + "columnsFrom": [ + "kiosk_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "announcement_kiosk_mtm_announcement_id_kiosk_id_pk": { + "name": "announcement_kiosk_mtm_announcement_id_kiosk_id_pk", + "columns": [ + "announcement_id", + "kiosk_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blog_post": { + "name": "blog_post", + "schema": "", + "columns": { + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "published_at": { + "name": "published_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "blog_post_author_id_user_id_fk": { + "name": "blog_post_author_id_user_id_fk", + "tableFrom": "blog_post", + "tableTo": "user", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "blog_post_slug_unique": { + "name": "blog_post_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_message": { + "name": "system_message", + "schema": "", + "columns": { + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "valid_from": { + "name": "valid_from", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "valid_until": { + "name": "valid_until", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "system_message_author_id_user_id_fk": { + "name": "system_message_author_id_user_id_fk", + "tableFrom": "system_message", + "tableTo": "user", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_message_cohort_mtm": { + "name": "system_message_cohort_mtm", + "schema": "", + "columns": { + "cohort_id": { + "name": "cohort_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "system_message_id": { + "name": "system_message_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "system_message_cohort_mtm_system_message_id_idx": { + "name": "system_message_cohort_mtm_system_message_id_idx", + "columns": [ + { + "expression": "system_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "system_message_cohort_mtm_cohort_id_idx": { + "name": "system_message_cohort_mtm_cohort_id_idx", + "columns": [ + { + "expression": "cohort_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "system_message_cohort_mtm_cohort_id_cohort_id_fk": { + "name": "system_message_cohort_mtm_cohort_id_cohort_id_fk", + "tableFrom": "system_message_cohort_mtm", + "tableTo": "cohort", + "columnsFrom": [ + "cohort_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "system_message_cohort_mtm_system_message_id_system_message_id_fk": { + "name": "system_message_cohort_mtm_system_message_id_system_message_id_fk", + "tableFrom": "system_message_cohort_mtm", + "tableTo": "system_message", + "columnsFrom": [ + "system_message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "system_message_cohort_mtm_system_message_id_cohort_id_pk": { + "name": "system_message_cohort_mtm_system_message_id_cohort_id_pk", + "columns": [ + "system_message_id", + "cohort_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.fcm_token": { + "name": "fcm_token", + "schema": "", + "columns": { + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "device_info": { + "name": "device_info", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "fcm_token_user_id_user_id_fk": { + "name": "fcm_token_user_id_user_id_fk", + "tableFrom": "fcm_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification": { + "name": "notification", + "schema": "", + "columns": { + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "read": { + "name": "read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "notification_user_id_created_at_idx": { + "name": "notification_user_id_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_created_at_idx": { + "name": "notification_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_user_id_user_id_fk": { + "name": "notification_user_id_user_id_fk", + "tableFrom": "notification", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_preferences": { + "name": "user_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'hu'" + }, + "notification_preferences": { + "name": "notification_preferences", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"announcement\":true,\"blogPost\":false,\"channelsEnabled\":true,\"doorlockCardUsed\":false,\"movedLesson\":true,\"substitution\":true,\"systemMessage\":true}'::jsonb" + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "timetable_class_colors": { + "name": "timetable_class_colors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "timetable_group_display": { + "name": "timetable_group_display", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'highlight'" + }, + "timetable_view": { + "name": "timetable_view", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'class'" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_user_id_fk": { + "name": "user_preferences_user_id_user_id_fk", + "tableFrom": "user_preferences", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_preferences_user_id_unique": { + "name": "user_preferences_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.building": { + "name": "building", + "schema": "", + "columns": { + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mapped": { + "name": "mapped", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "x": { + "name": "x", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "y": { + "name": "y", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.classroom": { + "name": "classroom", + "schema": "", + "columns": { + "building_id": { + "name": "building_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "capacity": { + "name": "capacity", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mapped": { + "name": "mapped", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rotation": { + "name": "rotation", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "short": { + "name": "short", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "size_x": { + "name": "size_x", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 6 + }, + "size_y": { + "name": "size_y", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 6 + }, + "size_z": { + "name": "size_z", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "storey": { + "name": "storey", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "type_id": { + "name": "type_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "x": { + "name": "x", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "y": { + "name": "y", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "classroom_building_id_idx": { + "name": "classroom_building_id_idx", + "columns": [ + { + "expression": "building_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "classroom_type_id_idx": { + "name": "classroom_type_id_idx", + "columns": [ + { + "expression": "type_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "classroom_building_id_building_id_fk": { + "name": "classroom_building_id_building_id_fk", + "tableFrom": "classroom", + "tableTo": "building", + "columnsFrom": [ + "building_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "classroom_type_id_classroom_type_id_fk": { + "name": "classroom_type_id_classroom_type_id_fk", + "tableFrom": "classroom", + "tableTo": "classroom_type", + "columnsFrom": [ + "type_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.classroom_type": { + "name": "classroom_type", + "schema": "", + "columns": { + "colorhex": { + "name": "colorhex", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cohort": { + "name": "cohort", + "schema": "", + "columns": { + "classroom_ids": { + "name": "classroom_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "short": { + "name": "short", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "teacher_id": { + "name": "teacher_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timetable_id": { + "name": "timetable_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "cohort_teacher_id_teacher_id_fk": { + "name": "cohort_teacher_id_teacher_id_fk", + "tableFrom": "cohort", + "tableTo": "teacher", + "columnsFrom": [ + "teacher_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cohort_timetable_id_timetable_id_fk": { + "name": "cohort_timetable_id_timetable_id_fk", + "tableFrom": "cohort", + "tableTo": "timetable", + "columnsFrom": [ + "timetable_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.group": { + "name": "group", + "schema": "", + "columns": { + "cohort_id": { + "name": "cohort_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "division_tag": { + "name": "division_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entire_class": { + "name": "entire_class", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "student_count": { + "name": "student_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "teacher_id": { + "name": "teacher_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "group_cohort_id_cohort_id_fk": { + "name": "group_cohort_id_cohort_id_fk", + "tableFrom": "group", + "tableTo": "cohort", + "columnsFrom": [ + "cohort_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "group_teacher_id_teacher_id_fk": { + "name": "group_teacher_id_teacher_id_fk", + "tableFrom": "group", + "tableTo": "teacher", + "columnsFrom": [ + "teacher_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cohort_timetable_mtm": { + "name": "cohort_timetable_mtm", + "schema": "", + "columns": { + "cohort_id": { + "name": "cohort_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timetable_id": { + "name": "timetable_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "cohort_timetable_mtm_timetable_id_idx": { + "name": "cohort_timetable_mtm_timetable_id_idx", + "columns": [ + { + "expression": "timetable_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cohort_timetable_mtm_cohort_id_cohort_id_fk": { + "name": "cohort_timetable_mtm_cohort_id_cohort_id_fk", + "tableFrom": "cohort_timetable_mtm", + "tableTo": "cohort", + "columnsFrom": [ + "cohort_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cohort_timetable_mtm_timetable_id_timetable_id_fk": { + "name": "cohort_timetable_mtm_timetable_id_timetable_id_fk", + "tableFrom": "cohort_timetable_mtm", + "tableTo": "timetable", + "columnsFrom": [ + "timetable_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "cohort_timetable_mtm_cohort_id_timetable_id_pk": { + "name": "cohort_timetable_mtm_cohort_id_timetable_id_pk", + "columns": [ + "cohort_id", + "timetable_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.day_definition": { + "name": "day_definition", + "schema": "", + "columns": { + "days": { + "name": "days", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "short": { + "name": "short", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lesson": { + "name": "lesson", + "schema": "", + "columns": { + "classroom_ids": { + "name": "classroom_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "day_definition_id": { + "name": "day_definition_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_ids": { + "name": "group_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "period_id": { + "name": "period_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "periods_per_week": { + "name": "periods_per_week", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "teacher_ids": { + "name": "teacher_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "term_definition_id": { + "name": "term_definition_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timetable_id": { + "name": "timetable_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "weeks_definition_id": { + "name": "weeks_definition_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "lesson_day_definition_id_day_definition_id_fk": { + "name": "lesson_day_definition_id_day_definition_id_fk", + "tableFrom": "lesson", + "tableTo": "day_definition", + "columnsFrom": [ + "day_definition_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "lesson_period_id_period_id_fk": { + "name": "lesson_period_id_period_id_fk", + "tableFrom": "lesson", + "tableTo": "period", + "columnsFrom": [ + "period_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "lesson_subject_id_subject_id_fk": { + "name": "lesson_subject_id_subject_id_fk", + "tableFrom": "lesson", + "tableTo": "subject", + "columnsFrom": [ + "subject_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "lesson_term_definition_id_term_definition_id_fk": { + "name": "lesson_term_definition_id_term_definition_id_fk", + "tableFrom": "lesson", + "tableTo": "term_definition", + "columnsFrom": [ + "term_definition_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "lesson_timetable_id_timetable_id_fk": { + "name": "lesson_timetable_id_timetable_id_fk", + "tableFrom": "lesson", + "tableTo": "timetable", + "columnsFrom": [ + "timetable_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "lesson_weeks_definition_id_week_definition_id_fk": { + "name": "lesson_weeks_definition_id_week_definition_id_fk", + "tableFrom": "lesson", + "tableTo": "week_definition", + "columnsFrom": [ + "weeks_definition_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lesson_cohort_mtm": { + "name": "lesson_cohort_mtm", + "schema": "", + "columns": { + "cohort_id": { + "name": "cohort_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lesson_id": { + "name": "lesson_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "lesson_cohort_mtm_cohort_id_cohort_id_fk": { + "name": "lesson_cohort_mtm_cohort_id_cohort_id_fk", + "tableFrom": "lesson_cohort_mtm", + "tableTo": "cohort", + "columnsFrom": [ + "cohort_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "lesson_cohort_mtm_lesson_id_lesson_id_fk": { + "name": "lesson_cohort_mtm_lesson_id_lesson_id_fk", + "tableFrom": "lesson_cohort_mtm", + "tableTo": "lesson", + "columnsFrom": [ + "lesson_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "lesson_cohort_mtm_lesson_id_cohort_id_pk": { + "name": "lesson_cohort_mtm_lesson_id_cohort_id_pk", + "columns": [ + "lesson_id", + "cohort_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.moved_lesson": { + "name": "moved_lesson", + "schema": "", + "columns": { + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "room": { + "name": "room", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "starting_day": { + "name": "starting_day", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "starting_period": { + "name": "starting_period", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "moved_lesson_room_classroom_id_fk": { + "name": "moved_lesson_room_classroom_id_fk", + "tableFrom": "moved_lesson", + "tableTo": "classroom", + "columnsFrom": [ + "room" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "moved_lesson_starting_day_day_definition_id_fk": { + "name": "moved_lesson_starting_day_day_definition_id_fk", + "tableFrom": "moved_lesson", + "tableTo": "day_definition", + "columnsFrom": [ + "starting_day" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "moved_lesson_starting_period_period_id_fk": { + "name": "moved_lesson_starting_period_period_id_fk", + "tableFrom": "moved_lesson", + "tableTo": "period", + "columnsFrom": [ + "starting_period" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.moved_lesson_lesson_mtm": { + "name": "moved_lesson_lesson_mtm", + "schema": "", + "columns": { + "lesson_id": { + "name": "lesson_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "moved_lesson_id": { + "name": "moved_lesson_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "moved_lesson_lesson_mtm_lesson_id_lesson_id_fk": { + "name": "moved_lesson_lesson_mtm_lesson_id_lesson_id_fk", + "tableFrom": "moved_lesson_lesson_mtm", + "tableTo": "lesson", + "columnsFrom": [ + "lesson_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "moved_lesson_lesson_mtm_moved_lesson_id_moved_lesson_id_fk": { + "name": "moved_lesson_lesson_mtm_moved_lesson_id_moved_lesson_id_fk", + "tableFrom": "moved_lesson_lesson_mtm", + "tableTo": "moved_lesson", + "columnsFrom": [ + "moved_lesson_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "moved_lesson_lesson_mtm_lesson_id_moved_lesson_id_pk": { + "name": "moved_lesson_lesson_mtm_lesson_id_moved_lesson_id_pk", + "columns": [ + "lesson_id", + "moved_lesson_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.period": { + "name": "period", + "schema": "", + "columns": { + "end_time": { + "name": "end_time", + "type": "time", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "period": { + "name": "period", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "start_time": { + "name": "start_time", + "type": "time", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subject": { + "name": "subject", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "short": { + "name": "short", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.substitution": { + "name": "substitution", + "schema": "", + "columns": { + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "substituter": { + "name": "substituter", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "substitution_substituter_teacher_id_fk": { + "name": "substitution_substituter_teacher_id_fk", + "tableFrom": "substitution", + "tableTo": "teacher", + "columnsFrom": [ + "substituter" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.substitution_lesson_mtm": { + "name": "substitution_lesson_mtm", + "schema": "", + "columns": { + "lesson_id": { + "name": "lesson_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "substitution_id": { + "name": "substitution_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "substitution_lesson_mtm_lesson_id_lesson_id_fk": { + "name": "substitution_lesson_mtm_lesson_id_lesson_id_fk", + "tableFrom": "substitution_lesson_mtm", + "tableTo": "lesson", + "columnsFrom": [ + "lesson_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "substitution_lesson_mtm_substitution_id_substitution_id_fk": { + "name": "substitution_lesson_mtm_substitution_id_substitution_id_fk", + "tableFrom": "substitution_lesson_mtm", + "tableTo": "substitution", + "columnsFrom": [ + "substitution_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "substitution_lesson_mtm_lesson_id_substitution_id_pk": { + "name": "substitution_lesson_mtm_lesson_id_substitution_id_pk", + "columns": [ + "lesson_id", + "substitution_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.teacher": { + "name": "teacher", + "schema": "", + "columns": { + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "gender": { + "name": "gender", + "type": "bit(1)", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "short": { + "name": "short", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "teacher_user_id_user_id_fk": { + "name": "teacher_user_id_user_id_fk", + "tableFrom": "teacher", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.term_definition": { + "name": "term_definition", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "short": { + "name": "short", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "terms": { + "name": "terms", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.timetable": { + "name": "timetable", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "valid_from": { + "name": "valid_from", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "valid_to": { + "name": "valid_to", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_group": { + "name": "user_group", + "schema": "", + "columns": { + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "user_group_user_id_idx": { + "name": "user_group_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_group_group_id_idx": { + "name": "user_group_group_id_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_group_group_id_group_id_fk": { + "name": "user_group_group_id_group_id_fk", + "tableFrom": "user_group", + "tableTo": "group", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_group_user_id_user_id_fk": { + "name": "user_group_user_id_user_id_fk", + "tableFrom": "user_group", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_group_group_id_user_id_pk": { + "name": "user_group_group_id_user_id_pk", + "columns": [ + "group_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.week_definition": { + "name": "week_definition", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "short": { + "name": "short", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "weeks": { + "name": "weeks", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wifi_auth_log": { + "name": "wifi_auth_log", + "schema": "", + "columns": { + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "wifi_auth_log_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "mac_address": { + "name": "mac_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "nas_ip_address": { + "name": "nas_ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nas_mac_address": { + "name": "nas_mac_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "wifi_user_id": { + "name": "wifi_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "wifi_auth_log_timestamp_idx": { + "name": "wifi_auth_log_timestamp_idx", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wifi_auth_log_username_idx": { + "name": "wifi_auth_log_username_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wifi_auth_log_mac_address_idx": { + "name": "wifi_auth_log_mac_address_idx", + "columns": [ + { + "expression": "mac_address", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wifi_auth_log_wifi_user_id_wifi_user_id_fk": { + "name": "wifi_auth_log_wifi_user_id_wifi_user_id_fk", + "tableFrom": "wifi_auth_log", + "tableTo": "wifi_user", + "columnsFrom": [ + "wifi_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wifi_device": { + "name": "wifi_device", + "schema": "", + "columns": { + "admin_notes": { + "name": "admin_notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "mac_address": { + "name": "mac_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "nickname": { + "name": "nickname", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reported_hostname": { + "name": "reported_hostname", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wifi_user_id": { + "name": "wifi_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wifi_device_mac_address_idx": { + "name": "wifi_device_mac_address_idx", + "columns": [ + { + "expression": "mac_address", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wifi_device_wifi_user_id_idx": { + "name": "wifi_device_wifi_user_id_idx", + "columns": [ + { + "expression": "wifi_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wifi_device_wifi_user_id_wifi_user_id_fk": { + "name": "wifi_device_wifi_user_id_wifi_user_id_fk", + "tableFrom": "wifi_device", + "tableTo": "wifi_user", + "columnsFrom": [ + "wifi_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "wifi_device_mac_address_unique": { + "name": "wifi_device_mac_address_unique", + "nullsNotDistinct": false, + "columns": [ + "mac_address" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wifi_nas": { + "name": "wifi_nas", + "schema": "", + "columns": { + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mac_address": { + "name": "mac_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wifi_nas_mac_address_idx": { + "name": "wifi_nas_mac_address_idx", + "columns": [ + { + "expression": "mac_address", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wifi_nas_ip_address_idx": { + "name": "wifi_nas_ip_address_idx", + "columns": [ + { + "expression": "ip_address", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "wifi_nas_mac_address_unique": { + "name": "wifi_nas_mac_address_unique", + "nullsNotDistinct": false, + "columns": [ + "mac_address" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wifi_role_speed_profile": { + "name": "wifi_role_speed_profile", + "schema": "", + "columns": { + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "role_name": { + "name": "role_name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "speed_profile_id": { + "name": "speed_profile_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wifi_role_speed_profile_role_name_idx": { + "name": "wifi_role_speed_profile_role_name_idx", + "columns": [ + { + "expression": "role_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wifi_role_speed_profile_speed_profile_id_wifi_speed_profile_id_fk": { + "name": "wifi_role_speed_profile_speed_profile_id_wifi_speed_profile_id_fk", + "tableFrom": "wifi_role_speed_profile", + "tableTo": "wifi_speed_profile", + "columnsFrom": [ + "speed_profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wifi_speed_profile": { + "name": "wifi_speed_profile", + "schema": "", + "columns": { + "download_speed_mbps": { + "name": "download_speed_mbps", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "is_wlan_default": { + "name": "is_wlan_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "synced_at": { + "name": "synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "upload_speed_mbps": { + "name": "upload_speed_mbps", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wifi_user": { + "name": "wifi_user", + "schema": "", + "columns": { + "allowed_mac_addresses": { + "name": "allowed_mac_addresses", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "salt": { + "name": "salt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "speed_profile_id": { + "name": "speed_profile_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wifi_user_username_idx": { + "name": "wifi_user_username_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wifi_user_user_id_idx": { + "name": "wifi_user_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wifi_user_created_by_user_id_fk": { + "name": "wifi_user_created_by_user_id_fk", + "tableFrom": "wifi_user", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "wifi_user_user_id_user_id_fk": { + "name": "wifi_user_user_id_user_id_fk", + "tableFrom": "wifi_user", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "wifi_user_username_unique": { + "name": "wifi_user_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/chronos/src/database/migrations/meta/_journal.json b/apps/chronos/src/database/migrations/meta/_journal.json index e3554f46..2ee46679 100644 --- a/apps/chronos/src/database/migrations/meta/_journal.json +++ b/apps/chronos/src/database/migrations/meta/_journal.json @@ -169,6 +169,13 @@ "when": 1791433967383, "tag": "0023_last_gorgon", "breakpoints": true + }, + { + "idx": 24, + "version": "7", + "when": 1791447969715, + "tag": "0024_moaning_marvel_boy", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/chronos/src/database/scripts/reset.ts b/apps/chronos/src/database/scripts/reset.ts index f40ad72b..05d0716e 100644 --- a/apps/chronos/src/database/scripts/reset.ts +++ b/apps/chronos/src/database/scripts/reset.ts @@ -8,6 +8,7 @@ import { authenticationSchema } from '#database/schema/authentication'; import { authorizationSchema } from '#database/schema/authorization'; import { doorlockSchema } from '#modules/doorlock/schema'; import { timetableSchema } from '#modules/timetable/schema'; +import { wifiSchema } from '#modules/wifi/schema'; import { configureLogger } from '#utils/logger'; await configureLogger('chronos'); @@ -39,6 +40,7 @@ const reset = async () => { ...(nukeAuth ? apiKeySchema : {}), ...doorlockSchema, ...timetableSchema, + ...wifiSchema, }; // disable foreign key checks diff --git a/apps/chronos/src/middleware/wifi.ts b/apps/chronos/src/middleware/wifi.ts new file mode 100644 index 00000000..f9a32ed9 --- /dev/null +++ b/apps/chronos/src/middleware/wifi.ts @@ -0,0 +1,15 @@ +import { base } from '#orpc'; +import { env } from '#utils/environment'; +import { serviceUnavailable } from '#utils/http'; + +/** + * Every WiFi procedure except `status` is unavailable while the module is off. + * `status` must stay reachable — it is what the apps use to decide whether to + * render any WiFi surface at all. + */ +export const requireWifiEnabled = base.middleware(({ next }) => { + if (!env.wifiEnabled) { + throw serviceUnavailable('WiFi is disabled.'); + } + return next(); +}); diff --git a/apps/chronos/src/modules/index.ts b/apps/chronos/src/modules/index.ts index f18fef5c..bb1c1e48 100644 --- a/apps/chronos/src/modules/index.ts +++ b/apps/chronos/src/modules/index.ts @@ -3,6 +3,7 @@ import type { Module } from '#modules/module'; import { newsModule } from '#modules/news/_module'; import { notificationsModule } from '#modules/notifications/_module'; import { timetableModule } from '#modules/timetable/_module'; +import { wifiModule } from '#modules/wifi/_module'; /** * Every module that contributes scheduled work or notifications. @@ -22,4 +23,5 @@ export const modules: readonly Module[] = [ newsModule, notificationsModule, timetableModule, + wifiModule, ]; diff --git a/apps/chronos/src/modules/schemas.ts b/apps/chronos/src/modules/schemas.ts index 31d42d30..b80320bd 100644 --- a/apps/chronos/src/modules/schemas.ts +++ b/apps/chronos/src/modules/schemas.ts @@ -5,6 +5,7 @@ import { navigatorSchema } from '#modules/navigator/schema'; import { newsSchema } from '#modules/news/schema'; import { notificationsSchema } from '#modules/notifications/schema'; import { timetableSchema } from '#modules/timetable/schema'; +import { wifiSchema } from '#modules/wifi/schema'; /** * Every module's tables, merged into the one schema map Drizzle is built from. @@ -27,4 +28,5 @@ export const moduleSchemas = { ...newsSchema, ...notificationsSchema, ...timetableSchema, + ...wifiSchema, }; diff --git a/apps/chronos/src/modules/wifi/_module.ts b/apps/chronos/src/modules/wifi/_module.ts new file mode 100644 index 00000000..e72df31f --- /dev/null +++ b/apps/chronos/src/modules/wifi/_module.ts @@ -0,0 +1,26 @@ +import type { Module } from '#modules/module'; +import { cleanUpWifiAuthLogs } from '#modules/wifi/utils/cleanup'; +import { syncSpeedProfiles } from '#modules/wifi/utils/sync'; +import { env } from '#utils/environment'; + +/** + * The WiFi module: its tables live in `schema.ts`, and its scheduled work is + * registered only while the module is enabled — a disabled module must not + * keep a UniFi round-trip on the hourly timer. + */ +export const wifiModule = { + jobs: env.wifiEnabled + ? [ + { + callback: cleanUpWifiAuthLogs, + cron: '@monthly', + name: 'clean-up-wifi-auth-logs', + }, + { + callback: syncSpeedProfiles, + cron: '@hourly', + name: 'sync-wifi-speed-profiles', + }, + ] + : [], +} satisfies Module; diff --git a/apps/chronos/src/modules/wifi/_router.ts b/apps/chronos/src/modules/wifi/_router.ts new file mode 100644 index 00000000..81049aad --- /dev/null +++ b/apps/chronos/src/modules/wifi/_router.ts @@ -0,0 +1,77 @@ +import { + createWifiDevice, + createWifiNas, + createWifiRoleProfile, + createWifiSpeedProfile, + createWifiUser, + deleteWifiDevice, + deleteWifiNas, + deleteWifiRoleProfile, + deleteWifiSpeedProfile, + deleteWifiUser, + listWifiAuthLogs, + listWifiDevices, + listWifiNas, + listWifiRoleProfiles, + listWifiSpeedProfiles, + listWifiUsers, + updateWifiDevice, + updateWifiNas, + updateWifiRoleProfile, + updateWifiSpeedProfile, + updateWifiUser, +} from '#modules/wifi/admin'; +import { authorizeRadiusHandler } from '#modules/wifi/radius'; +import { + createSelfWifi, + getSelfWifi, + getSelfWifiCertificate, + updateSelfWifiDevice, + updateSelfWifiPassword, +} from '#modules/wifi/self'; +import { wifiStats } from '#modules/wifi/stats'; +import { wifiStatus } from '#modules/wifi/status'; + +export const wifiRouter = { + authLogs: { list: listWifiAuthLogs }, + devices: { + create: createWifiDevice, + delete: deleteWifiDevice, + list: listWifiDevices, + update: updateWifiDevice, + }, + nas: { + create: createWifiNas, + delete: deleteWifiNas, + list: listWifiNas, + update: updateWifiNas, + }, + radius: { authorize: authorizeRadiusHandler }, + roleSpeedProfiles: { + create: createWifiRoleProfile, + delete: deleteWifiRoleProfile, + list: listWifiRoleProfiles, + update: updateWifiRoleProfile, + }, + self: { + certificate: getSelfWifiCertificate, + changePassword: updateSelfWifiPassword, + create: createSelfWifi, + get: getSelfWifi, + updateDevice: updateSelfWifiDevice, + }, + speedProfiles: { + create: createWifiSpeedProfile, + delete: deleteWifiSpeedProfile, + list: listWifiSpeedProfiles, + update: updateWifiSpeedProfile, + }, + stats: { overview: wifiStats }, + status: wifiStatus, + users: { + create: createWifiUser, + delete: deleteWifiUser, + list: listWifiUsers, + update: updateWifiUser, + }, +}; diff --git a/apps/chronos/src/modules/wifi/admin.ts b/apps/chronos/src/modules/wifi/admin.ts new file mode 100644 index 00000000..6fc72efe --- /dev/null +++ b/apps/chronos/src/modules/wifi/admin.ts @@ -0,0 +1,560 @@ +import { permissions } from '@filcdev/api/permissions'; +import { ORPCError } from '@orpc/server'; +import { + and, + asc, + desc, + eq, + getTableColumns, + ilike, + isNull, + or, + sql, +} from 'drizzle-orm'; +import { db } from '#database'; +import { user } from '#database/schema/authentication'; +import { requireAuthorization } from '#middleware/auth'; +import { requireWifiEnabled } from '#middleware/wifi'; +import { base } from '#orpc'; +import { env } from '#utils/environment'; +import { badRequest, notFound, serviceUnavailable } from '#utils/http'; +import { + wifiAuthLog, + wifiDevice, + wifiNas, + wifiRoleSpeedProfile, + wifiSpeedProfile, + wifiUser, +} from './schema'; +import { getWifiController, type WifiSpeedProfile } from './utils/controller'; +import { encryptPassword } from './utils/encryptor'; +import { canonicalizeMac } from './utils/mac'; + +/** + * `exactOptionalPropertyTypes` rejects an explicit `undefined` for an optional + * Drizzle column, and a `.partial()` payload carries every field the caller + * omitted as `undefined`. Drop them instead of narrowing each handler to a + * hand-written assignment per field. + */ +const presentFields = ( + value: T +): { [K in keyof T]?: Exclude } => + Object.fromEntries( + Object.entries(value).filter(([, entry]) => entry !== undefined) + ) as { [K in keyof T]?: Exclude }; + +const configured = () => { + const controller = getWifiController(); + if (!controller) { + throw serviceUnavailable('No WiFi controller is configured'); + } + return controller; +}; + +/** + * `wifi_user` has no `last_active_at` column — activity belongs to the devices. + * The wire field is the most recent of the account's devices, so the list is + * one query with the aggregate inlined instead of a second round trip. + */ +const lastActiveAtSubquery = sql`(select max(${wifiDevice.lastActiveAt}) from ${wifiDevice} where ${wifiDevice.wifiUserId} = ${wifiUser.id})`; + +const userColumns = { + ...getTableColumns(wifiUser), + creatorName: user.name, + lastActiveAt: lastActiveAtSubquery, +}; + +const fetchUserById = async (id: string) => { + const [row] = await db + .select(userColumns) + .from(wifiUser) + .leftJoin(user, eq(wifiUser.createdBy, user.id)) + .where(eq(wifiUser.id, id)) + .limit(1); + return row; +}; + +const serializeSpeedProfile = ( + profile: WifiSpeedProfile | undefined, + isWlanDefault = false +) => { + if (!profile) { + throw serviceUnavailable('UniFi did not return a speed profile'); + } + return { + downloadSpeedMbps: profile.downloadSpeedMbps ?? null, + id: profile.id, + isWlanDefault, + name: profile.name, + uploadSpeedMbps: profile.uploadSpeedMbps ?? null, + }; +}; + +export const listWifiUsers = base.wifi.users.list + .use(requireWifiEnabled) + .use(requireAuthorization(permissions.wifiRead)) + .handler(async ({ input }) => { + const { limit, offset, search } = input; + const users = await db + .select(userColumns) + .from(wifiUser) + .leftJoin(user, eq(wifiUser.createdBy, user.id)) + .where(search ? ilike(wifiUser.username, `%${search}%`) : undefined) + .orderBy(asc(wifiUser.createdAt)) + .limit(limit) + .offset(offset); + + return { users }; + }); + +export const createWifiUser = base.wifi.users.create + .use(requireWifiEnabled) + .use(requireAuthorization(permissions.wifiWrite)) + .handler(async ({ context, input }) => { + if (!env.wifiEncryptionSecret) { + throw serviceUnavailable('WiFi encryption is not configured'); + } + + const encrypted = await encryptPassword( + input.password, + input.username, + env.wifiEncryptionSecret + ); + + const [created] = await db + .insert(wifiUser) + .values({ + allowedMacAddresses: input.allowedMacAddresses, + comment: input.comment, + createdBy: context.session.userId, + encryptedPassword: encrypted.encryptedPassword, + salt: encrypted.salt, + speedProfileId: input.speedProfileId, + userId: input.userId, + username: input.username, + }) + .returning({ id: wifiUser.id }); + + if (!created) { + throw new ORPCError('INTERNAL', { + message: 'Failed to create WiFi user', + status: 500, + }); + } + + const row = await fetchUserById(created.id); + if (!row) { + throw notFound('WiFi user not found'); + } + + return { user: row }; + }); + +export const updateWifiUser = base.wifi.users.update + .use(requireWifiEnabled) + .use(requireAuthorization(permissions.wifiWrite)) + .handler(async ({ input }) => { + const { id, password, ...fields } = input; + const [existing] = await db + .select() + .from(wifiUser) + .where(eq(wifiUser.id, id)) + .limit(1); + if (!existing) { + throw notFound('WiFi user not found'); + } + if (fields.username && fields.username !== existing.username && !password) { + throw badRequest('Changing the username requires changing the password'); + } + + const update: Partial = { + ...presentFields(fields), + updatedAt: new Date(), + }; + if (password) { + const username = fields.username ?? existing.username; + if (!env.wifiEncryptionSecret) { + throw badRequest( + 'Username and encryption configuration are required to change the password' + ); + } + const encrypted = await encryptPassword( + password, + username, + env.wifiEncryptionSecret + ); + update.encryptedPassword = encrypted.encryptedPassword; + update.salt = encrypted.salt; + } + + const [updated] = await db + .update(wifiUser) + .set(update) + .where(eq(wifiUser.id, id)) + .returning({ id: wifiUser.id }); + if (!updated) { + throw notFound('WiFi user not found'); + } + + const row = await fetchUserById(updated.id); + if (!row) { + throw notFound('WiFi user not found'); + } + + return { user: row }; + }); + +export const deleteWifiUser = base.wifi.users.delete + .use(requireWifiEnabled) + .use(requireAuthorization(permissions.wifiWrite)) + .handler(async ({ input }) => { + const [deleted] = await db + .delete(wifiUser) + .where(eq(wifiUser.id, input.id)) + .returning({ id: wifiUser.id }); + if (!deleted) { + throw notFound('WiFi user not found'); + } + + return { id: deleted.id }; + }); + +/** `null` means "unowned devices"; `undefined` means "no owner filter". */ +const ownerFilter = (wifiUserId: string | null | undefined) => { + if (wifiUserId === null) { + return isNull(wifiDevice.wifiUserId); + } + if (wifiUserId === undefined) { + return undefined; + } + return eq(wifiDevice.wifiUserId, wifiUserId); +}; + +export const listWifiDevices = base.wifi.devices.list + .use(requireWifiEnabled) + .use(requireAuthorization(permissions.wifiRead)) + .handler(async ({ input }) => { + const { limit, offset, search, wifiUserId } = input; + const devices = await db + .select() + .from(wifiDevice) + .where( + and( + search ? ilike(wifiDevice.macAddress, `%${search}%`) : undefined, + ownerFilter(wifiUserId) + ) + ) + .orderBy(desc(wifiDevice.updatedAt)) + .limit(limit) + .offset(offset); + + return { devices }; + }); + +export const createWifiDevice = base.wifi.devices.create + .use(requireWifiEnabled) + .use(requireAuthorization(permissions.wifiWrite)) + .handler(async ({ input }) => { + const [device] = await db + .insert(wifiDevice) + .values({ ...input, macAddress: canonicalizeMac(input.macAddress) }) + .returning(); + if (!device) { + throw notFound('WiFi device not found'); + } + + return { device }; + }); + +export const updateWifiDevice = base.wifi.devices.update + .use(requireWifiEnabled) + .use(requireAuthorization(permissions.wifiWrite)) + .handler(async ({ input }) => { + const { id, macAddress, ...fields } = input; + const [device] = await db + .update(wifiDevice) + .set({ + ...fields, + ...(macAddress ? { macAddress: canonicalizeMac(macAddress) } : {}), + updatedAt: new Date(), + }) + .where(eq(wifiDevice.id, id)) + .returning(); + if (!device) { + throw notFound('WiFi device not found'); + } + + return { device }; + }); + +export const deleteWifiDevice = base.wifi.devices.delete + .use(requireWifiEnabled) + .use(requireAuthorization(permissions.wifiWrite)) + .handler(async ({ input }) => { + const [deleted] = await db + .delete(wifiDevice) + .where(eq(wifiDevice.id, input.id)) + .returning({ id: wifiDevice.id }); + if (!deleted) { + throw notFound('WiFi device not found'); + } + + return { id: deleted.id }; + }); + +export const listWifiNas = base.wifi.nas.list + .use(requireWifiEnabled) + .use(requireAuthorization(permissions.wifiRead)) + .handler(async () => ({ + nas: await db.select().from(wifiNas).orderBy(asc(wifiNas.ipAddress)), + })); + +export const createWifiNas = base.wifi.nas.create + .use(requireWifiEnabled) + .use(requireAuthorization(permissions.wifiWrite)) + .handler(async ({ input }) => { + const [nas] = await db + .insert(wifiNas) + .values({ ...input, macAddress: canonicalizeMac(input.macAddress) }) + .returning(); + if (!nas) { + throw notFound('WiFi NAS not found'); + } + + return { nas }; + }); + +export const updateWifiNas = base.wifi.nas.update + .use(requireWifiEnabled) + .use(requireAuthorization(permissions.wifiWrite)) + .handler(async ({ input }) => { + const { id, macAddress, ...fields } = input; + const [nas] = await db + .update(wifiNas) + .set({ + ...fields, + ...(macAddress ? { macAddress: canonicalizeMac(macAddress) } : {}), + updatedAt: new Date(), + }) + .where(eq(wifiNas.id, id)) + .returning(); + if (!nas) { + throw notFound('WiFi NAS not found'); + } + + return { nas }; + }); + +export const deleteWifiNas = base.wifi.nas.delete + .use(requireWifiEnabled) + .use(requireAuthorization(permissions.wifiWrite)) + .handler(async ({ input }) => { + const [deleted] = await db + .delete(wifiNas) + .where(eq(wifiNas.id, input.id)) + .returning({ id: wifiNas.id }); + if (!deleted) { + throw notFound('WiFi NAS not found'); + } + + return { id: deleted.id }; + }); + +export const listWifiSpeedProfiles = base.wifi.speedProfiles.list + .use(requireWifiEnabled) + .use(requireAuthorization(permissions.wifiRead)) + .handler(async () => { + const profiles = await db + .select() + .from(wifiSpeedProfile) + .orderBy(asc(wifiSpeedProfile.name)); + + return { + speedProfiles: profiles.map((profile) => + serializeSpeedProfile( + { + downloadSpeedMbps: profile.downloadSpeedMbps, + id: profile.id, + name: profile.name, + uploadSpeedMbps: profile.uploadSpeedMbps, + }, + profile.isWlanDefault + ) + ), + }; + }); + +export const createWifiSpeedProfile = base.wifi.speedProfiles.create + .use(requireWifiEnabled) + .use(requireAuthorization(permissions.wifiWrite)) + .handler(async ({ input }) => { + const profile = await configured().createSpeedProfile(input); + + await db.insert(wifiSpeedProfile).values({ + downloadSpeedMbps: profile.downloadSpeedMbps ?? null, + id: profile.id, + isWlanDefault: false, + name: profile.name, + uploadSpeedMbps: profile.uploadSpeedMbps ?? null, + }); + + return { profile: serializeSpeedProfile(profile) }; + }); + +export const updateWifiSpeedProfile = base.wifi.speedProfiles.update + .use(requireWifiEnabled) + .use(requireAuthorization(permissions.wifiWrite)) + .handler(async ({ input }) => { + const controller = configured(); + const [current] = await db + .select() + .from(wifiSpeedProfile) + .where(eq(wifiSpeedProfile.id, input.id)) + .limit(1); + if (!current) { + throw notFound('WiFi speed profile not found'); + } + + const profile = await controller.updateSpeedProfile(input.id, { + downloadSpeedMbps: + input.downloadSpeedMbps ?? current.downloadSpeedMbps ?? -1, + name: input.name ?? current.name, + uploadSpeedMbps: input.uploadSpeedMbps ?? current.uploadSpeedMbps ?? -1, + }); + + const [updated] = await db + .update(wifiSpeedProfile) + .set({ + downloadSpeedMbps: profile.downloadSpeedMbps ?? null, + name: profile.name, + syncedAt: new Date(), + uploadSpeedMbps: profile.uploadSpeedMbps ?? null, + }) + .where(eq(wifiSpeedProfile.id, profile.id)) + .returning({ isWlanDefault: wifiSpeedProfile.isWlanDefault }); + + return { + profile: serializeSpeedProfile(profile, updated?.isWlanDefault ?? false), + }; + }); + +export const deleteWifiSpeedProfile = base.wifi.speedProfiles.delete + .use(requireWifiEnabled) + .use(requireAuthorization(permissions.wifiWrite)) + .handler(async ({ input }) => { + await configured().deleteSpeedProfile(input.id); + await db.delete(wifiSpeedProfile).where(eq(wifiSpeedProfile.id, input.id)); + + return { id: input.id }; + }); + +export const listWifiRoleProfiles = base.wifi.roleSpeedProfiles.list + .use(requireWifiEnabled) + .use(requireAuthorization(permissions.wifiRead)) + .handler(async () => ({ + roleSpeedProfiles: await db + .select() + .from(wifiRoleSpeedProfile) + .orderBy(desc(wifiRoleSpeedProfile.priority)), + })); + +export const createWifiRoleProfile = base.wifi.roleSpeedProfiles.create + .use(requireWifiEnabled) + .use(requireAuthorization(permissions.wifiWrite)) + .handler(async ({ input }) => { + const [mapping] = await db + .insert(wifiRoleSpeedProfile) + .values(input) + .returning(); + if (!mapping) { + throw notFound('WiFi role mapping not found'); + } + + return { mapping }; + }); + +export const updateWifiRoleProfile = base.wifi.roleSpeedProfiles.update + .use(requireWifiEnabled) + .use(requireAuthorization(permissions.wifiWrite)) + .handler(async ({ input }) => { + const { id, ...fields } = input; + const [mapping] = await db + .update(wifiRoleSpeedProfile) + .set(fields) + .where(eq(wifiRoleSpeedProfile.roleName, id)) + .returning(); + if (!mapping) { + throw notFound('WiFi role mapping not found'); + } + + return { mapping }; + }); + +export const deleteWifiRoleProfile = base.wifi.roleSpeedProfiles.delete + .use(requireWifiEnabled) + .use(requireAuthorization(permissions.wifiWrite)) + .handler(async ({ input }) => { + const [deleted] = await db + .delete(wifiRoleSpeedProfile) + .where(eq(wifiRoleSpeedProfile.roleName, input.id)) + .returning({ roleName: wifiRoleSpeedProfile.roleName }); + if (!deleted) { + throw notFound('WiFi role mapping not found'); + } + + return { id: deleted.roleName }; + }); + +export const listWifiAuthLogs = base.wifi.authLogs.list + .use(requireWifiEnabled) + .use(requireAuthorization(permissions.wifiRead)) + .handler(async ({ input }) => { + const { limit, offset, search, failureReason, result } = input; + const logs = await db + .select({ + deviceNickname: wifiDevice.nickname, + deviceReportedHostname: wifiDevice.reportedHostname, + failureReason: wifiAuthLog.failureReason, + id: wifiAuthLog.id, + macAddress: wifiAuthLog.macAddress, + nasComment: wifiNas.comment, + nasIpAddress: wifiAuthLog.nasIpAddress, + nasMacAddress: wifiAuthLog.nasMacAddress, + result: wifiAuthLog.result, + timestamp: wifiAuthLog.timestamp, + userComment: wifiUser.comment, + username: wifiAuthLog.username, + wifiUserId: wifiAuthLog.wifiUserId, + }) + .from(wifiAuthLog) + .leftJoin( + wifiNas, + or( + eq(wifiAuthLog.nasIpAddress, wifiNas.ipAddress), + eq(wifiAuthLog.nasMacAddress, wifiNas.macAddress) + ) + ) + .leftJoin(wifiDevice, eq(wifiAuthLog.macAddress, wifiDevice.macAddress)) + .leftJoin(wifiUser, eq(wifiAuthLog.username, wifiUser.username)) + .where( + and( + search + ? or( + ilike(wifiAuthLog.username, `%${search}%`), + ilike(wifiAuthLog.macAddress, `%${search}%`), + ilike(wifiAuthLog.nasIpAddress, `%${search}%`), + ilike(wifiAuthLog.failureReason, `%${search}%`) + ) + : undefined, + failureReason + ? eq(wifiAuthLog.failureReason, failureReason) + : undefined, + result === undefined ? undefined : eq(wifiAuthLog.result, result) + ) + ) + .orderBy(desc(wifiAuthLog.timestamp)) + .limit(limit) + .offset(offset); + + return { logs }; + }); diff --git a/apps/chronos/src/modules/wifi/radius.ts b/apps/chronos/src/modules/wifi/radius.ts new file mode 100644 index 00000000..439d92e2 --- /dev/null +++ b/apps/chronos/src/modules/wifi/radius.ts @@ -0,0 +1,43 @@ +import { ORPCError } from '@orpc/server'; +import { requireWifiEnabled } from '#middleware/wifi'; +import { base } from '#orpc'; +import { env } from '#utils/environment'; +import { forbidden, notFound } from '#utils/http'; +import { getWifiController } from './utils/controller'; +import { authorizeRadius } from './utils/radius'; + +export const authorizeRadiusHandler = base.wifi.radius.authorize + .use(requireWifiEnabled) + .handler(async ({ context, input }) => { + const sharedSecret = + context.reqHeaders.get('X-FreeRADIUS-Secret') ?? input.sharedSecret; + + const result = await authorizeRadius( + { ...input, sharedSecret }, + { + controller: getWifiController() ?? undefined, + encryptionSecret: env.wifiEncryptionSecret ?? '', + freeradiusIp: env.freeradiusIp ?? '', + realIp: context.clientIp, + sharedSecret: env.freeradiusSharedSecret ?? '', + } + ); + + switch (result.status) { + case 200: + return result.body as { + 'Cleartext-Password': string; + 'control:Cleartext-Password': string; + 'Session-Timeout': number; + }; + case 403: + throw forbidden(result.body.message as string); + case 404: + throw notFound(result.body.message as string); + default: + throw new ORPCError('INTERNAL', { + message: result.body.message as string, + status: 500, + }); + } + }); diff --git a/apps/chronos/src/modules/wifi/schema.ts b/apps/chronos/src/modules/wifi/schema.ts new file mode 100644 index 00000000..aa1fd56e --- /dev/null +++ b/apps/chronos/src/modules/wifi/schema.ts @@ -0,0 +1,149 @@ +import { + boolean, + index, + integer, + pgTable, + text, + timestamp, + uniqueIndex, + uuid, +} from 'drizzle-orm/pg-core'; +import { timestamps } from '#database/helpers'; +import { user } from '#database/schema/authentication'; + +/** + * WiFi user accounts + * Represents both Entra-linked and manually-created WiFi accounts + */ +export const wifiUser = pgTable( + 'wifi_user', + { + allowedMacAddresses: text('allowed_mac_addresses').array(), + banned: boolean('banned').default(false).notNull(), + comment: text('comment'), + createdBy: uuid('created_by').references(() => user.id, { + onDelete: 'set null', + }), + encryptedPassword: text('encrypted_password').notNull(), + id: uuid('id').primaryKey().defaultRandom(), + salt: text('salt').notNull(), + // No foreign key: the module stores its `__none__` sentinel here, and an + // FK would reject it. Dangling references are cleared by the profile sync. + speedProfileId: text('speed_profile_id'), + userId: uuid('user_id').references(() => user.id, { onDelete: 'set null' }), + username: text('username').notNull().unique(), + ...timestamps, + }, + (t) => [ + uniqueIndex('wifi_user_username_idx').on(t.username), + index('wifi_user_user_id_idx').on(t.userId), + ] +); + +/** + * WiFi devices (client MACs) + * Unified table for assigned devices and global MAC banlist + * Serves both user-owned devices and system-wide ban entries + */ +export const wifiDevice = pgTable( + 'wifi_device', + { + adminNotes: text('admin_notes'), + banned: boolean('banned').default(false).notNull(), + id: uuid('id').primaryKey().defaultRandom(), + lastActiveAt: timestamp('last_active_at'), + macAddress: text('mac_address').notNull().unique(), + nickname: text('nickname'), + reportedHostname: text('reported_hostname'), + wifiUserId: uuid('wifi_user_id').references(() => wifiUser.id, { + onDelete: 'set null', + }), + ...timestamps, + }, + (t) => [ + uniqueIndex('wifi_device_mac_address_idx').on(t.macAddress), + index('wifi_device_wifi_user_id_idx').on(t.wifiUserId), + ] +); + +/** + * Network Access Servers (FreeRADIUS-connected APs) + * Registry of authorized access points + */ +export const wifiNas = pgTable( + 'wifi_nas', + { + comment: text('comment'), + id: uuid('id').primaryKey().defaultRandom(), + ipAddress: text('ip_address').notNull(), + macAddress: text('mac_address').notNull().unique(), + ...timestamps, + }, + (t) => [ + uniqueIndex('wifi_nas_mac_address_idx').on(t.macAddress), + uniqueIndex('wifi_nas_ip_address_idx').on(t.ipAddress), + ] +); + +/** + * WiFi authentication log + * Audit trail for all RADIUS auth attempts (accept and reject) + * Used for troubleshooting, stats, and retention policies + */ +export const wifiAuthLog = pgTable( + 'wifi_auth_log', + { + failureReason: text('failure_reason'), + id: integer('id').primaryKey().generatedByDefaultAsIdentity(), + macAddress: text('mac_address').notNull(), + nasIpAddress: text('nas_ip_address'), + nasMacAddress: text('nas_mac_address'), + result: boolean('result').notNull(), + timestamp: timestamp('timestamp').notNull().defaultNow(), + username: text('username').notNull(), + wifiUserId: uuid('wifi_user_id').references(() => wifiUser.id, { + onDelete: 'set null', + }), + }, + (t) => [ + index('wifi_auth_log_timestamp_idx').on(t.timestamp), + index('wifi_auth_log_username_idx').on(t.username), + index('wifi_auth_log_mac_address_idx').on(t.macAddress), + ] +); + +/** + * Role-based speed profile mappings + * Maps filc RBAC roles to UniFi speed profile IDs with configurable limits + * Used at auth time to resolve effective speed limits + */ +export const wifiRoleSpeedProfile = pgTable( + 'wifi_role_speed_profile', + { + priority: integer('priority').default(0).notNull(), + roleName: text('role_name').primaryKey(), + speedProfileId: text('speed_profile_id') + .notNull() + .references(() => wifiSpeedProfile.id, { onDelete: 'cascade' }), + ...timestamps, + }, + (t) => [index('wifi_role_speed_profile_role_name_idx').on(t.roleName)] +); + +export const wifiSpeedProfile = pgTable('wifi_speed_profile', { + downloadSpeedMbps: integer('download_speed_mbps'), + id: text('id').primaryKey(), + isWlanDefault: boolean('is_wlan_default').default(false).notNull(), + name: text('name').notNull(), + syncedAt: timestamp('synced_at').notNull().defaultNow(), + uploadSpeedMbps: integer('upload_speed_mbps'), +}); + +export const wifiSchema = { + wifiAuthLog, + wifiDevice, + wifiNas, + wifiRoleSpeedProfile, + wifiSpeedProfile, + wifiUser, +}; diff --git a/apps/chronos/src/modules/wifi/self.ts b/apps/chronos/src/modules/wifi/self.ts new file mode 100644 index 00000000..c4d6107a --- /dev/null +++ b/apps/chronos/src/modules/wifi/self.ts @@ -0,0 +1,324 @@ +import { existsSync, statSync } from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { getLogger } from '@logtape/logtape'; +import { ORPCError } from '@orpc/server'; +import { and, eq, isNull, sql } from 'drizzle-orm'; +import { db } from '#database'; +import { requireAuthentication } from '#middleware/auth'; +import { requireWifiEnabled } from '#middleware/wifi'; +import { base } from '#orpc'; +import { env } from '#utils/environment'; +import { + badRequest, + conflict, + notFound, + serviceUnavailable, +} from '#utils/http'; +import { wifiDevice, wifiUser } from './schema'; +import { encryptPassword } from './utils/encryptor'; +import { resolveEffectiveSpeedProfileDetails } from './utils/speed-profile'; + +const logger = getLogger(['chronos', 'wifi', 'self']); + +/** + * Find the account of a signed-in user: the linked row, or an unclaimed legacy + * row whose username is exactly their (case-insensitive) email address. A + * found unclaimed row is linked before it is returned. + * + * Only a full-email match qualifies. Matching the email's local part would let + * any user whose local part collides with an unclaimed wifi username take over + * that account, password included. + */ +const findAccount = async (userId: string, email?: string | null) => { + const [linked] = await db + .select() + .from(wifiUser) + .where(eq(wifiUser.userId, userId)) + .limit(1); + if (linked || !email) { + return linked; + } + + const [unclaimed] = await db + .select() + .from(wifiUser) + .where( + and( + sql`lower(${wifiUser.username}) = lower(${email})`, + isNull(wifiUser.userId) + ) + ) + .limit(1); + if (!unclaimed) { + return undefined; + } + + const [updated] = await db + .update(wifiUser) + .set({ userId }) + .where(eq(wifiUser.id, unclaimed.id)) + .returning(); + return updated; +}; + +const findCertFile = (targetPath: string): string | null => { + if (!existsSync(targetPath)) { + return null; + } + try { + const stat = statSync(targetPath); + if (stat.isFile()) { + return targetPath; + } + if (stat.isDirectory()) { + const caPem = path.join(targetPath, 'ca.pem'); + if (existsSync(caPem)) { + return caPem; + } + const caCrt = path.join(targetPath, 'ca.crt'); + if (existsSync(caCrt)) { + return caCrt; + } + } + } catch { + return null; + } + return null; +}; + +/** + * Resolve the configured CA path. A relative path is resolved against the + * current working directory (which differs between `bun dev` at the repo root + * and at `apps/chronos`) and against the Chronos package root. + */ +export const resolveWifiCaCertPath = ( + configuredPath?: string +): string | null => { + if (!configuredPath) { + return null; + } + + const normalized = configuredPath.trim(); + if (!normalized) { + return null; + } + + const candidates = path.isAbsolute(normalized) + ? [normalized] + : [ + path.resolve(process.cwd(), normalized), + path.resolve(import.meta.dir, '../../..', normalized), + ]; + + for (const candidate of candidates) { + const certFile = findCertFile(candidate); + if (certFile) { + return certFile; + } + } + + return null; +}; + +export const createSelfWifi = base.wifi.self.create + .use(requireWifiEnabled) + .use(requireAuthentication) + .handler(async ({ context, input }) => { + const userEmail = context.user?.email ?? null; + const userId = context.session.userId; + if (!userEmail) { + throw badRequest('User email is required'); + } + + if (await findAccount(userId, userEmail)) { + throw conflict('WiFi account already exists'); + } + + if (!env.wifiEncryptionSecret) { + throw serviceUnavailable('WiFi encryption is not configured'); + } + + const encrypted = await encryptPassword( + input.password, + userEmail, + env.wifiEncryptionSecret + ); + + const [account] = await db + .insert(wifiUser) + .values({ + banned: false, + createdBy: userId, + encryptedPassword: encrypted.encryptedPassword, + salt: encrypted.salt, + userId, + username: userEmail, + }) + .returning(); + + if (!account) { + throw new ORPCError('INTERNAL', { + message: 'Failed to create WiFi account', + status: 500, + }); + } + + const speedLimit = await resolveEffectiveSpeedProfileDetails(account); + const devices = await db + .select() + .from(wifiDevice) + .where(eq(wifiDevice.wifiUserId, account.id)); + + return { + wifi: { + banned: account.banned, + devices, + speedLimit, + username: account.username, + }, + }; + }); + +export const getSelfWifi = base.wifi.self.get + .use(requireWifiEnabled) + .use(requireAuthentication) + .handler(async ({ context }) => { + const account = await findAccount( + context.session.userId, + context.user?.email + ); + if (!account) { + throw notFound('WiFi account not found'); + } + + const devices = await db + .select() + .from(wifiDevice) + .where(eq(wifiDevice.wifiUserId, account.id)); + const speed = await resolveEffectiveSpeedProfileDetails(account); + + return { + wifi: { + banned: account.banned, + devices, + speedLimit: { + downloadSpeedMbps: speed.downloadSpeedMbps, + roleName: speed.roleName, + source: speed.source, + speedProfileId: speed.speedProfileId, + uploadSpeedMbps: speed.uploadSpeedMbps, + }, + username: account.username, + }, + }; + }); + +export const updateSelfWifiDevice = base.wifi.self.updateDevice + .use(requireWifiEnabled) + .use(requireAuthentication) + .handler(async ({ context, input }) => { + const account = await findAccount( + context.session.userId, + context.user?.email + ); + if (!account) { + throw notFound('WiFi account not found'); + } + + const [device] = await db + .update(wifiDevice) + .set({ nickname: input.nickname, updatedAt: new Date() }) + .where( + and(eq(wifiDevice.id, input.id), eq(wifiDevice.wifiUserId, account.id)) + ) + .returning(); + if (!device) { + throw notFound('WiFi device not found'); + } + + return { device }; + }); + +export const updateSelfWifiPassword = base.wifi.self.changePassword + .use(requireWifiEnabled) + .use(requireAuthentication) + .handler(async ({ context, input }) => { + const account = await findAccount( + context.session.userId, + context.user?.email + ); + if (!account) { + throw notFound('WiFi account not found'); + } + + if (!env.wifiEncryptionSecret) { + throw serviceUnavailable('WiFi encryption is not configured'); + } + + const encrypted = await encryptPassword( + input.newPassword, + account.username, + env.wifiEncryptionSecret + ); + await db + .update(wifiUser) + .set({ + encryptedPassword: encrypted.encryptedPassword, + salt: encrypted.salt, + updatedAt: new Date(), + }) + .where(eq(wifiUser.id, account.id)); + + return { success: true }; + }); + +export const getSelfWifiCertificate = base.wifi.self.certificate + .use(requireWifiEnabled) + .use(requireAuthentication) + .handler(async ({ context }) => { + const account = await findAccount( + context.session.userId, + context.user?.email + ); + if (!account) { + throw notFound('WiFi account not found'); + } + + const certPath = env.wifiCaCertPath; + if (!certPath) { + throw serviceUnavailable('WiFi CA certificate is not configured'); + } + + const resolved = resolveWifiCaCertPath(certPath); + if (!resolved) { + logger.error('WiFi CA certificate file not found: {certPath}', { + certPath, + }); + throw new ORPCError('INTERNAL', { + message: 'Failed to read CA certificate file', + status: 500, + }); + } + + let pem: string; + try { + pem = await Bun.file(resolved).text(); + } catch (error) { + logger.error('Failed to read CA certificate file: {error}', { + error: String(error), + }); + throw new ORPCError('INTERNAL', { + message: 'Failed to read CA certificate file', + status: 500, + }); + } + + context.resHeaders?.set( + 'Content-Disposition', + 'attachment; filename="wifi-ca.pem"' + ); + return new File([pem], 'wifi-ca.pem', { + type: 'application/x-pem-file', + }); + }); diff --git a/apps/chronos/src/modules/wifi/stats.ts b/apps/chronos/src/modules/wifi/stats.ts new file mode 100644 index 00000000..55fc7f37 --- /dev/null +++ b/apps/chronos/src/modules/wifi/stats.ts @@ -0,0 +1,51 @@ +import { permissions } from '@filcdev/api/permissions'; +import { count, gte, sql } from 'drizzle-orm'; +import { db } from '#database'; +import { requireAuthorization } from '#middleware/auth'; +import { requireWifiEnabled } from '#middleware/wifi'; +import { base } from '#orpc'; +import { wifiAuthLog, wifiDevice, wifiUser } from './schema'; + +const ACTIVE_WINDOW_MS = 24 * 60 * 60 * 1000; +const SERIES_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; + +export const wifiStats = base.wifi.stats.overview + .use(requireWifiEnabled) + .use(requireAuthorization(permissions.wifiRead)) + .handler(async () => { + const [users, devices, activeDevices] = await Promise.all([ + db.select({ count: count() }).from(wifiUser), + db.select({ count: count() }).from(wifiDevice), + db + .select({ count: count() }) + .from(wifiDevice) + .where( + gte(wifiDevice.lastActiveAt, new Date(Date.now() - ACTIVE_WINDOW_MS)) + ), + ]); + + const day = sql`date_trunc('day', ${wifiAuthLog.timestamp})`; + const rows = await db + .select({ + accepted: sql`count(*) filter (where ${wifiAuthLog.result} = true)`, + date: sql`to_char(${day}, 'YYYY-MM-DD')`, + rejected: sql`count(*) filter (where ${wifiAuthLog.result} = false)`, + }) + .from(wifiAuthLog) + .where( + gte(wifiAuthLog.timestamp, new Date(Date.now() - SERIES_WINDOW_MS)) + ) + .groupBy(day) + .orderBy(day); + + return { + activeDevices: Number(activeDevices[0]?.count ?? 0), + authSeries: rows.map((row) => ({ + accepted: Number(row.accepted), + date: row.date, + rejected: Number(row.rejected), + })), + totalDevices: Number(devices[0]?.count ?? 0), + totalUsers: Number(users[0]?.count ?? 0), + }; + }); diff --git a/apps/chronos/src/modules/wifi/status.ts b/apps/chronos/src/modules/wifi/status.ts new file mode 100644 index 00000000..a8463704 --- /dev/null +++ b/apps/chronos/src/modules/wifi/status.ts @@ -0,0 +1,7 @@ +import { base } from '#orpc'; +import { env } from '#utils/environment'; + +export const wifiStatus = base.wifi.status.handler(() => ({ + enabled: env.wifiEnabled, + ssid: env.wifiSsid || null, +})); diff --git a/apps/chronos/src/modules/wifi/utils/cleanup.ts b/apps/chronos/src/modules/wifi/utils/cleanup.ts new file mode 100644 index 00000000..db648282 --- /dev/null +++ b/apps/chronos/src/modules/wifi/utils/cleanup.ts @@ -0,0 +1,25 @@ +import { getLogger } from '@logtape/logtape'; +import { captureException } from '@sentry/bun'; +import { lte } from 'drizzle-orm'; +import { db } from '#database'; +import { wifiAuthLog } from '#modules/wifi/schema'; + +const logger = getLogger(['chronos', 'wifi', 'cleanup']); + +export async function cleanUpWifiAuthLogs() { + logger.info('Starting WiFi auth log cleanup'); + try { + const ninetyDaysAgo = new Date(); + ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90); + + const deleted = await db + .delete(wifiAuthLog) + .where(lte(wifiAuthLog.timestamp, ninetyDaysAgo)) + .returning({ id: wifiAuthLog.id }); + + logger.info(`Cleaned up ${deleted.length} old WiFi auth logs`); + } catch (error) { + logger.error('Failed to clean up WiFi auth logs: {error}', { error }); + captureException(error); + } +} diff --git a/apps/chronos/src/modules/wifi/utils/constants.ts b/apps/chronos/src/modules/wifi/utils/constants.ts new file mode 100644 index 00000000..7cfb5a57 --- /dev/null +++ b/apps/chronos/src/modules/wifi/utils/constants.ts @@ -0,0 +1,3 @@ +export const SPEED_PROFILE_NONE = '__none__'; +export const RADIUS_HEALTH_USERNAME = 'radtest'; +export const RADIUS_SESSION_TIMEOUT_SECONDS = 60 * 60; diff --git a/apps/chronos/src/modules/wifi/utils/controller.ts b/apps/chronos/src/modules/wifi/utils/controller.ts new file mode 100644 index 00000000..23abbefe --- /dev/null +++ b/apps/chronos/src/modules/wifi/utils/controller.ts @@ -0,0 +1,64 @@ +import { env } from '#utils/environment'; +import { createUnifiController } from './unifi-controller'; + +export type WifiSpeedProfile = { + id: string; + name: string; + downloadSpeedMbps: number | null; + uploadSpeedMbps: number | null; +}; + +export type WifiSpeedProfileInput = { + name: string; + downloadSpeedMbps: number; + uploadSpeedMbps: number; +}; + +export type WifiController = { + readonly provider: string; + fetchClientHostname(macAddress: string): Promise; + updateClientName(macAddress: string, name: string): Promise; + applySpeedProfile(macAddress: string, speedProfileId: string): Promise; + getSpeedProfiles(): Promise; + getWlanDefaultSpeedProfile(ssid: string): Promise; + createSpeedProfile(input: WifiSpeedProfileInput): Promise; + updateSpeedProfile( + id: string, + input: WifiSpeedProfileInput + ): Promise; + deleteSpeedProfile(id: string): Promise; +}; + +let controller: WifiController | null | undefined; + +export const getWifiController = (): WifiController | null => { + if (controller !== undefined) { + return controller; + } + + switch (env.wifiControllerProvider) { + case 'unifi': + if ( + !( + env.unifiHost && + env.unifiPort && + env.unifiUsername && + env.unifiPassword + ) + ) { + controller = null; + return controller; + } + controller = createUnifiController({ + host: env.unifiHost, + insecureTls: env.unifiInsecureTls, + password: env.unifiPassword, + port: env.unifiPort, + username: env.unifiUsername, + }); + return controller; + default: + controller = null; + return controller; + } +}; diff --git a/apps/chronos/src/modules/wifi/utils/encryptor.ts b/apps/chronos/src/modules/wifi/utils/encryptor.ts new file mode 100644 index 00000000..0edc69b9 --- /dev/null +++ b/apps/chronos/src/modules/wifi/utils/encryptor.ts @@ -0,0 +1,146 @@ +import { + createCipheriv, + createDecipheriv, + randomBytes, + type ScryptOptions, + scrypt, +} from 'node:crypto'; + +const ALGORITHM = 'aes-256-gcm'; +const AUTH_TAG_BYTE_LENGTH = 16; +const IV_BYTE_LENGTH = 12; +const KEY_BYTE_LENGTH = 32; +const SALT_BYTE_LENGTH = 32; + +/** + * `scryptSync` blocks the event loop for ~100 ms per call, and this runs on the + * RADIUS hot path, so every derivation goes through the async form. + */ +const scryptAsync = ( + password: Buffer | string, + salt: Buffer, + keylen: number, + options: ScryptOptions +): Promise => { + const { promise, resolve, reject } = Promise.withResolvers(); + scrypt(password, salt, keylen, options, (error, derivedKey) => { + if (error) { + reject(error); + return; + } + resolve(derivedKey); + }); + return promise; +}; + +const SCRYPT_OPTIONS = { + N: 16_384, // Cost parameter (CPU/memory cost) + p: 1, // Parallelization + r: 8, // Block size +}; + +export const getSalt = (): Buffer => randomBytes(SALT_BYTE_LENGTH); + +export const getKeyFromPassword = async ( + password: Buffer | string, + salt: Buffer +): Promise => + (await scryptAsync( + password, + salt, + KEY_BYTE_LENGTH, + SCRYPT_OPTIONS + )) as Buffer; + +export const getUserEncryptionKey = async ( + username: string, + secret: string, + salt: Buffer +): Promise<{ key: Buffer; passwordBuffer: Buffer }> => { + const passwordBuffer = Buffer.from(`${username}-${secret}`, 'utf8'); + return { + key: await getKeyFromPassword(passwordBuffer, salt), + passwordBuffer, + }; +}; + +export const encrypt = (message: Buffer, key: Buffer): Buffer => { + const iv = randomBytes(IV_BYTE_LENGTH); + const cipher = createCipheriv(ALGORITHM, key, iv, { + authTagLength: AUTH_TAG_BYTE_LENGTH, + }); + + const encrypted = Buffer.concat([cipher.update(message), cipher.final()]); + return Buffer.concat([iv, encrypted, cipher.getAuthTag()]); +}; + +export const decrypt = (ciphertext: Buffer, key: Buffer): Buffer => { + if (ciphertext.length < IV_BYTE_LENGTH + AUTH_TAG_BYTE_LENGTH) { + throw new Error('Ciphertext is too short to be valid'); + } + + const iv = ciphertext.subarray(0, IV_BYTE_LENGTH); + const authTag = ciphertext.subarray(-AUTH_TAG_BYTE_LENGTH); + const encrypted = ciphertext.subarray(IV_BYTE_LENGTH, -AUTH_TAG_BYTE_LENGTH); + + const decipher = createDecipheriv(ALGORITHM, key, iv, { + authTagLength: AUTH_TAG_BYTE_LENGTH, + }); + + decipher.setAuthTag(authTag); + return Buffer.concat([decipher.update(encrypted), decipher.final()]); +}; + +export const encryptPassword = async ( + password: string, + username: string, + secret: string, + salt = getSalt() +): Promise<{ encryptedPassword: string; salt: string }> => { + const { key, passwordBuffer } = await getUserEncryptionKey( + username, + secret, + salt + ); + + const plaintextBuffer = Buffer.from(password, 'utf8'); + const encrypted = encrypt(plaintextBuffer, key); + + // Cleanup buffers + passwordBuffer.fill(0); + key.fill(0); + plaintextBuffer.fill(0); + + return { + encryptedPassword: encrypted.toString('hex'), + salt: salt.toString('hex'), + }; +}; + +export const decryptPassword = async ( + encryptedPassword: string, + username: string, + secret: string, + salt: string +): Promise => { + const encrypted = Buffer.from(encryptedPassword, 'hex'); + const saltBuffer = Buffer.from(salt, 'hex'); + + const { key, passwordBuffer } = await getUserEncryptionKey( + username, + secret, + saltBuffer + ); + + const decryptedBuffer = decrypt(encrypted, key); + const result = decryptedBuffer.toString('utf8'); + + // Cleanup buffers. + passwordBuffer.fill(0); + key.fill(0); + saltBuffer.fill(0); + encrypted.fill(0); + decryptedBuffer.fill(0); + + return result; +}; diff --git a/apps/chronos/src/modules/wifi/utils/mac.ts b/apps/chronos/src/modules/wifi/utils/mac.ts new file mode 100644 index 00000000..531a90c0 --- /dev/null +++ b/apps/chronos/src/modules/wifi/utils/mac.ts @@ -0,0 +1,18 @@ +const MAC_PATTERN = /^[0-9a-f]{12}$/; + +export const canonicalizeMac = (value: string): string => { + const mac = value + .trim() + .toLowerCase() + .replaceAll(':', '') + .replaceAll('-', ''); + if (!MAC_PATTERN.test(mac)) { + throw new Error(`Invalid MAC address: ${value}`); + } + return mac; +}; + +export const formatMac = (value: string): string => { + const mac = canonicalizeMac(value); + return mac.match(/.{2}/g)?.join(':').toUpperCase() ?? mac; +}; diff --git a/apps/chronos/src/modules/wifi/utils/radius.ts b/apps/chronos/src/modules/wifi/utils/radius.ts new file mode 100644 index 00000000..1e09cfaa --- /dev/null +++ b/apps/chronos/src/modules/wifi/utils/radius.ts @@ -0,0 +1,328 @@ +import { getLogger } from '@logtape/logtape'; +import { and, eq } from 'drizzle-orm'; +import { db } from '#database'; +import { wifiAuthLog, wifiDevice, wifiNas, wifiUser } from '../schema'; +import { + RADIUS_HEALTH_USERNAME, + RADIUS_SESSION_TIMEOUT_SECONDS, +} from './constants'; +import type { WifiController } from './controller'; +import { decryptPassword } from './encryptor'; +import { canonicalizeMac } from './mac'; +import { resolveEffectiveSpeedProfile } from './speed-profile'; + +const logger = getLogger(['chronos', 'wifi', 'radius']); + +export type RadiusAuthorizeResult = { + body: Record; + status: 200 | 400 | 403 | 404 | 500; +}; + +export type RadiusAuthorizeOptions = { + encryptionSecret: string; + freeradiusIp: string; + realIp?: string | undefined; + sessionTimeoutSeconds?: number; + sharedSecret: string; + controller?: WifiController | undefined; +}; + +type RadiusRequest = { + IP?: string | undefined; + NAS?: string | undefined; + destination?: string | undefined; + password?: string | undefined; + sharedSecret?: string | undefined; + source: string; + username: string; +}; + +const reject = ( + status: RadiusAuthorizeResult['status'], + message: string, + code: string +) => ({ + body: { code, error: status === 404 ? 'Not Found' : 'Forbidden', message }, + status, +}); + +const normalizeSourceMac = (value: string): string => { + try { + return canonicalizeMac(value); + } catch { + return value.trim().toLowerCase(); + } +}; + +const normalizeNasMac = (value: string | undefined): string | null => { + if (!value) { + return null; + } + try { + return canonicalizeMac(value); + } catch { + return null; + } +}; + +type RadiusLogger = ( + result: boolean, + failureReason: string | null, + wifiUserId?: string +) => Promise; + +const validateRequestSource = async ( + request: RadiusRequest, + options: RadiusAuthorizeOptions, + nasMac: string | null, + log: RadiusLogger +): Promise => { + if (request.username === RADIUS_HEALTH_USERNAME) { + if (request.source !== '02-00-00-00-00-01' || request.IP !== '127.0.0.1') { + logger.warn('Radius health check forbidden source {source}', { + source: request.source, + }); + await log(false, 'forbidden_source'); + return reject( + 403, + 'You are not allowed to access this resource.', + 'FORBIDDEN' + ); + } + return null; + } + if (options.realIp !== options.freeradiusIp) { + logger.warn( + 'Radius request from untrusted IP {ip} (expected {freeradiusIp})', + { freeradiusIp: options.freeradiusIp, ip: options.realIp } + ); + await log(false, 'forbidden_source'); + return reject( + 403, + 'You are not allowed to access this resource.', + 'FORBIDDEN' + ); + } + if (!(nasMac && request.IP)) { + logger.warn('Radius request missing NAS or IP {nas} {ip}', { + ip: request.IP, + nas: request.NAS, + }); + await log(false, 'nas_not_found'); + return reject( + 403, + 'You are not allowed to access this resource.', + 'FORBIDDEN' + ); + } + const [nas] = await db + .select() + .from(wifiNas) + .where( + and(eq(wifiNas.macAddress, nasMac), eq(wifiNas.ipAddress, request.IP)) + ) + .limit(1); + if (!nas) { + logger.warn('Radius request from unknown NAS {nasMac} / {ip}', { + ip: request.IP, + nasMac, + }); + await log(false, 'nas_not_found'); + return reject( + 403, + 'You are not allowed to access this resource.', + 'FORBIDDEN' + ); + } + return null; +}; + +const deviceUpdateTimeouts = new Map>(); + +const enrichControllerDevice = ( + mac: string, + account: typeof wifiUser.$inferSelect, + controller: WifiController +): void => { + if (deviceUpdateTimeouts.has(mac)) { + return; + } + const timeout = setTimeout(() => { + Promise.all([ + controller + .fetchClientHostname(mac) + .then((reportedHostname) => + reportedHostname + ? db + .update(wifiDevice) + .set({ reportedHostname, updatedAt: new Date() }) + .where(eq(wifiDevice.macAddress, mac)) + : undefined + ), + resolveEffectiveSpeedProfile(account).then((profileId) => + profileId ? controller.applySpeedProfile(mac, profileId) : undefined + ), + controller.updateClientName(mac, account.username), + ]) + .catch((error) => { + logger.error('Error in enrichControllerDevice: {error}', { error }); + }) + .finally(() => { + deviceUpdateTimeouts.delete(mac); + }); + }, 180 * 1000); + deviceUpdateTimeouts.set(mac, timeout); +}; + +export const authorizeRadius = async ( + request: RadiusRequest, + options: RadiusAuthorizeOptions +): Promise => { + const mac = normalizeSourceMac(request.source); + const nasMac = normalizeNasMac(request.NAS); + const log = async ( + result: boolean, + failureReason: string | null, + wifiUserId?: string + ) => { + await db.insert(wifiAuthLog).values({ + failureReason, + macAddress: mac, + nasIpAddress: request.IP ?? null, + nasMacAddress: nasMac, + result, + username: request.username, + wifiUserId, + }); + }; + if (!options.sharedSecret || request.sharedSecret !== options.sharedSecret) { + logger.warn('Radius request with invalid shared secret from {source}', { + source: request.source, + }); + await log(false, 'invalid_shared_secret'); + return reject( + 403, + 'You are not allowed to access this resource.', + 'FORBIDDEN' + ); + } + const sourceError = await validateRequestSource( + request, + options, + nasMac, + log + ); + if (sourceError) { + return sourceError; + } + const [globalDevice] = await db + .select({ banned: wifiDevice.banned }) + .from(wifiDevice) + .where(eq(wifiDevice.macAddress, mac)) + .limit(1); + if (globalDevice?.banned) { + logger.warn('Radius request from globally banned MAC {mac}', { mac }); + await log(false, 'mac_banned_global'); + return reject( + 403, + 'You are not allowed to access this resource.', + 'FORBIDDEN' + ); + } + const [account] = await db + .select() + .from(wifiUser) + .where(eq(wifiUser.username, request.username)) + .limit(1); + if (!account) { + logger.debug('Radius request for unknown user {username}', { + username: request.username, + }); + await log(false, 'not_found'); + return reject(404, 'The requested user was not found.', 'NOT_FOUND'); + } + if (account.banned) { + logger.warn('Radius request for banned user {username}', { + username: request.username, + }); + await log(false, 'banned', account.id); + return reject( + 403, + 'You are not allowed to access this resource.', + 'FORBIDDEN' + ); + } + const allowedMacs = account.allowedMacAddresses ?? []; + if ( + allowedMacs.length > 0 && + !allowedMacs.some((allowed) => { + try { + return canonicalizeMac(allowed) === mac; + } catch { + return false; + } + }) + ) { + logger.warn( + 'Radius request for user {username} from non-whitelisted MAC {mac}', + { mac, username: request.username } + ); + await log(false, 'not_whitelisted', account.id); + return reject( + 403, + 'This device is not allowed for this user.', + 'NOT_WHITELISTED' + ); + } + try { + const password = await decryptPassword( + account.encryptedPassword, + account.username, + options.encryptionSecret, + account.salt + ); + await db + .insert(wifiDevice) + .values({ + lastActiveAt: new Date(), + macAddress: mac, + wifiUserId: account.id, + }) + .onConflictDoUpdate({ + set: { + lastActiveAt: new Date(), + updatedAt: new Date(), + wifiUserId: account.id, + }, + target: wifiDevice.macAddress, + }); + logger.info('Successfully authorized {username} on {mac}', { + mac, + username: request.username, + }); + await log(true, null, account.id); + if (options.controller) { + enrichControllerDevice(mac, account, options.controller); + } + return { + body: { + 'Cleartext-Password': password, + 'control:Cleartext-Password': password, + 'Session-Timeout': + options.sessionTimeoutSeconds ?? RADIUS_SESSION_TIMEOUT_SECONDS, + }, + status: 200, + }; + } catch (e) { + logger.error('Internal error authorizing radius user {username}: {error}', { + error: e, + username: request.username, + }); + await log(false, 'internal_error', account.id); + return reject( + 500, + 'Internal error while authorizing WiFi user.', + 'INTERNAL_ERROR' + ); + } +}; diff --git a/apps/chronos/src/modules/wifi/utils/speed-profile.ts b/apps/chronos/src/modules/wifi/utils/speed-profile.ts new file mode 100644 index 00000000..6b91569e --- /dev/null +++ b/apps/chronos/src/modules/wifi/utils/speed-profile.ts @@ -0,0 +1,104 @@ +import { desc, eq, inArray } from 'drizzle-orm'; +import { db } from '#database'; +import { user } from '#database/schema/authentication'; +import { + wifiRoleSpeedProfile, + wifiSpeedProfile, + type wifiUser, +} from '#modules/wifi/schema'; +import { SPEED_PROFILE_NONE } from './constants'; + +export type EffectiveSpeedProfile = { + roleName: string | null; + source: 'override' | 'role' | 'wlan_default'; + speedProfileId: string | null; + downloadSpeedMbps: number | null; + uploadSpeedMbps: number | null; +}; + +export const resolveEffectiveSpeedProfile = async ( + account: typeof wifiUser.$inferSelect +): Promise => { + const details = await resolveEffectiveSpeedProfileDetails(account); + return details.speedProfileId; +}; + +const resolveLimits = async (profileId: string | null) => { + if (!profileId || profileId === SPEED_PROFILE_NONE) { + return { downloadSpeedMbps: null, uploadSpeedMbps: null }; + } + const [profile] = await db + .select({ + down: wifiSpeedProfile.downloadSpeedMbps, + up: wifiSpeedProfile.uploadSpeedMbps, + }) + .from(wifiSpeedProfile) + .where(eq(wifiSpeedProfile.id, profileId)) + .limit(1); + return { + downloadSpeedMbps: profile?.down ?? null, + uploadSpeedMbps: profile?.up ?? null, + }; +}; + +export const resolveEffectiveSpeedProfileDetails = async ( + account: typeof wifiUser.$inferSelect +): Promise => { + let source: EffectiveSpeedProfile['source'] = 'wlan_default'; + let speedProfileId: string | null = null; + let roleName: string | null = null; + + if (account.speedProfileId && account.speedProfileId !== SPEED_PROFILE_NONE) { + source = 'override'; + speedProfileId = account.speedProfileId; + } else if (account.speedProfileId !== SPEED_PROFILE_NONE && account.userId) { + const [filcUser] = await db + .select({ roles: user.roles }) + .from(user) + .where(eq(user.id, account.userId)) + .limit(1); + + if (filcUser?.roles?.length) { + const [mapping] = await db + .select() + .from(wifiRoleSpeedProfile) + .where(inArray(wifiRoleSpeedProfile.roleName, filcUser.roles)) + .orderBy(desc(wifiRoleSpeedProfile.priority)) + .limit(1); + + if (mapping) { + source = 'role'; + speedProfileId = mapping.speedProfileId; + roleName = mapping.roleName; + } + } + } + + // The controller itself will apply the wlan default speed limit. + let limits = await resolveLimits(speedProfileId); + + if (source === 'wlan_default') { + const [defaultProfile] = await db + .select({ + down: wifiSpeedProfile.downloadSpeedMbps, + up: wifiSpeedProfile.uploadSpeedMbps, + }) + .from(wifiSpeedProfile) + .where(eq(wifiSpeedProfile.isWlanDefault, true)) + .limit(1); + + if (defaultProfile) { + limits = { + downloadSpeedMbps: defaultProfile.down ?? null, + uploadSpeedMbps: defaultProfile.up ?? null, + }; + } + } + + return { + roleName, + source, + speedProfileId, + ...limits, + }; +}; diff --git a/apps/chronos/src/modules/wifi/utils/sync.ts b/apps/chronos/src/modules/wifi/utils/sync.ts new file mode 100644 index 00000000..8e76c967 --- /dev/null +++ b/apps/chronos/src/modules/wifi/utils/sync.ts @@ -0,0 +1,80 @@ +import { and, lt, notExists, sql } from 'drizzle-orm'; +import { db } from '#database'; +import { env } from '#utils/environment'; +import { wifiSpeedProfile, wifiUser } from '../schema'; +import { SPEED_PROFILE_NONE } from './constants'; +import { getWifiController } from './controller'; + +/** + * Mirror the UniFi controller's speed profiles into the local table. + * + * The whole run is one transaction: a fetch or upsert that fails part-way would + * otherwise leave a partial set of rows, and the closing delete removes every + * profile the run did not touch — i.e. everything, if the upserts never ran. + */ +export async function syncSpeedProfiles() { + const controller = getWifiController(); + if (!controller) { + return; + } + + const profiles = await controller.getSpeedProfiles(); + let defaultProfileId: string | null = null; + if (env.wifiSsid) { + defaultProfileId = await controller.getWlanDefaultSpeedProfile( + env.wifiSsid + ); + } + const syncedAt = new Date(); + + await db.transaction(async (tx) => { + // Upsert all fetched profiles + for (const profile of profiles) { + const isWlanDefault = profile.id === defaultProfileId; + await tx + .insert(wifiSpeedProfile) + .values({ + downloadSpeedMbps: profile.downloadSpeedMbps ?? null, + id: profile.id, + isWlanDefault, + name: profile.name, + syncedAt, + uploadSpeedMbps: profile.uploadSpeedMbps ?? null, + }) + .onConflictDoUpdate({ + set: { + downloadSpeedMbps: profile.downloadSpeedMbps ?? null, + isWlanDefault, + name: profile.name, + syncedAt, + uploadSpeedMbps: profile.uploadSpeedMbps ?? null, + }, + target: wifiSpeedProfile.id, + }); + } + + // Delete profiles that no longer exist in the controller + await tx + .delete(wifiSpeedProfile) + .where(lt(wifiSpeedProfile.syncedAt, syncedAt)); + + // `wifi_user.speed_profile_id` has no foreign key (the module stores its + // `__none__` sentinel there), so the delete above can orphan a reference. + // The sentinel is not a profile id and must survive: it means "no limit", + // which is not the same as the role/wlan-default fallback a null takes. + await tx + .update(wifiUser) + .set({ speedProfileId: null }) + .where( + and( + sql`${wifiUser.speedProfileId} <> ${SPEED_PROFILE_NONE}`, + notExists( + tx + .select() + .from(wifiSpeedProfile) + .where(sql`${wifiSpeedProfile.id} = ${wifiUser.speedProfileId}`) + ) + ) + ); + }); +} diff --git a/apps/chronos/src/modules/wifi/utils/unifi-api.ts b/apps/chronos/src/modules/wifi/utils/unifi-api.ts new file mode 100644 index 00000000..c1f6127f --- /dev/null +++ b/apps/chronos/src/modules/wifi/utils/unifi-api.ts @@ -0,0 +1,320 @@ +export type UnifiConfig = { + baseUrl: string; + insecureTls?: boolean; + password: string; + site?: string; + username: string; +}; + +export type UnifiSpeedProfile = { + _id: string; + name: string; + qos_rate_max_down?: number; + qos_rate_max_up?: number; +}; + +type UnifiResponse = { data?: T; meta?: { msg?: string; rc?: string } }; + +export type UnifiClientDevice = { + _id: string; + hostname?: string; +}; + +export type UnifiAccessDevice = { + adopted: boolean; + mac: string; + name?: string; + uptime: number; +}; + +export type UnifiWlanConf = { + _id: string; + name: string; + usergroup_id?: string; + enabled: boolean; +}; + +export class UnifiApiError extends Error { + readonly code: string | undefined; + + constructor(message: string, code?: string) { + super(message); + this.name = 'UnifiApiError'; + this.code = code; + } +} + +const LOGIN_REQUIRED = 'UNIFI_LOGIN_REQUIRED'; + +/** A stalled controller must not pin a MAC in the enrichment debounce forever. */ +const REQUEST_TIMEOUT_MS = 10_000; + +export class UnifiClient { + private readonly config: Required; + private cookies: string[] = []; + private csrfToken: string | null = null; + private isUnifiOS = false; + private isInitialized = false; + + constructor(config: UnifiConfig) { + this.config = { insecureTls: false, site: 'default', ...config }; + } + + /** + * Per-request TLS bypass for a self-signed controller certificate. Bun + * accepts `tls.rejectUnauthorized` on a fetch init, so this stays scoped to + * this client instead of the process-wide `NODE_TLS_REJECT_UNAUTHORIZED=0` + * the PR set. + */ + private baseInit(init: RequestInit = {}): RequestInit { + const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS); + return { + ...init, + signal: init.signal ? AbortSignal.any([init.signal, timeout]) : timeout, + ...(this.config.insecureTls + ? { tls: { rejectUnauthorized: false } } + : {}), + }; + } + + // Probe the controller to determine if it is UniFi OS or Legacy + private async initializeEnvironment(): Promise { + if (this.isInitialized) { + return; + } + + const response = await fetch( + this.config.baseUrl, + this.baseInit({ redirect: 'manual' }) + ); + + // Legacy controllers redirect to /manage. UniFi OS returns 200 OK. + if ( + response.status === 302 && + response.headers.get('location')?.includes('/manage') + ) { + this.isUnifiOS = false; + } else { + this.isUnifiOS = true; + } + this.isInitialized = true; + } + + // Parse and store cookies properly + private updateTokens(response: Response) { + const rawCookies = response.headers.getSetCookie + ? response.headers.getSetCookie() + : []; + if (rawCookies.length > 0) { + // Extract just the key=value portions + this.cookies = rawCookies.flatMap((c) => { + const cookie = c.split(';')[0]; + return cookie ? [cookie] : []; + }); + } + + const csrf = response.headers.get('x-csrf-token'); + if (csrf) { + this.csrfToken = csrf; + } + } + + private getUrl(path: string): string { + const cleanPath = path.startsWith('/') ? path.slice(1) : path; + if ( + this.isUnifiOS && + !cleanPath.includes('login') && + !cleanPath.includes('logout') + ) { + return `${this.config.baseUrl}/proxy/network/${cleanPath}`; + } + return `${this.config.baseUrl}/${cleanPath}`; + } + + private async login(): Promise { + await this.initializeEnvironment(); + + const loginPath = this.isUnifiOS ? '/api/auth/login' : '/api/login'; + const response = await fetch( + `${this.config.baseUrl}${loginPath}`, + this.baseInit({ + body: JSON.stringify({ + password: this.config.password, + username: this.config.username, + }), + headers: { 'content-type': 'application/json' }, + method: 'POST', + }) + ); + + if (!response.ok) { + throw new Error(`UniFi login failed with status ${response.status}`); + } + + this.updateTokens(response); + + const body = (await response.json()) as UnifiResponse; + if (body.meta?.rc === 'error') { + throw new Error(body.meta.msg ?? 'UniFi login failed'); + } + } + + async request(path: string, init: RequestInit = {}): Promise { + const execute = async () => { + const headers = new Headers(init.headers); + headers.set('content-type', 'application/json'); + + if (this.cookies.length > 0) { + headers.set('cookie', this.cookies.join('; ')); + } + if (this.csrfToken) { + headers.set('x-csrf-token', this.csrfToken); + } + + const targetUrl = this.getUrl(path); + const response = await fetch( + targetUrl, + this.baseInit({ ...init, headers }) + ); + + this.updateTokens(response); + + if (response.status === 401) { + throw new Error(LOGIN_REQUIRED); + } + if (!response.ok) { + throw new Error(`UniFi request failed with status ${response.status}`); + } + + const body = (await response.json()) as UnifiResponse; + if (body.meta?.rc === 'error') { + if (body.meta.msg === 'api.err.LoginRequired') { + throw new Error(LOGIN_REQUIRED); + } + throw new UnifiApiError( + body.meta.msg ?? 'UniFi request failed', + body.meta.msg + ); + } + return body.data as T; + }; + + if (this.cookies.length === 0) { + await this.login(); + } + + try { + return await execute(); + } catch (error) { + if (error instanceof Error && error.message === LOGIN_REQUIRED) { + this.cookies = []; // Clear state to force re-auth + await this.login(); + return execute(); + } + throw error; + } + } + + // --- Endpoints --- + + getClientDevice = async (macAddress: string): Promise => + this.request(`/api/s/${this.config.site}/stat/user/${macAddress}`); + + setClientName = async (clientId: string, name: string): Promise => + this.request(`/api/s/${this.config.site}/rest/user/${clientId}`, { + body: JSON.stringify({ name }), + method: 'PUT', + }); + + getAccessDevices = async (): Promise => + this.request(`/api/s/${this.config.site}/stat/device`); + + restartDevice = async ( + macAddress: string, + rebootType: 'soft' | 'hard' = 'soft' + ): Promise => + this.request(`/api/s/${this.config.site}/cmd/devmgr`, { + body: JSON.stringify({ + cmd: 'restart', + mac: macAddress, + reboot_type: rebootType, + }), + method: 'POST', + }); + + getUserGroups = async (): Promise => + this.request(`/api/s/${this.config.site}/rest/usergroup`); + + getWlanConf = async (): Promise => + this.request(`/api/s/${this.config.site}/rest/wlanconf`); + + createUserGroup = async ( + name: string, + downloadMbps = -1, + uploadMbps = -1 + ): Promise => { + const res = await this.request( + `/api/s/${this.config.site}/rest/usergroup`, + { + body: JSON.stringify({ + name, + qos_rate_max_down: downloadMbps === -1 ? -1 : downloadMbps * 1000, + qos_rate_max_up: uploadMbps === -1 ? -1 : uploadMbps * 1000, + }), + method: 'POST', + } + ); + if (!res[0]) { + throw new Error('UniFi returned empty array'); + } + return res[0]; + }; + + editUserGroup = async ( + id: string, + siteId: string, + name: string, + downloadMbps: number, + uploadMbps: number + ): Promise => { + const res = await this.request( + `/api/s/${siteId}/rest/usergroup/${id}`, + { + body: JSON.stringify({ + name, + qos_rate_max_down: downloadMbps === -1 ? -1 : downloadMbps * 1000, + qos_rate_max_up: uploadMbps === -1 ? -1 : uploadMbps * 1000, + }), + method: 'PUT', + } + ); + if (!res[0]) { + throw new Error('UniFi returned empty array'); + } + return res[0]; + }; + + deleteUserGroup = async (id: string): Promise => + this.request(`/api/s/${this.config.site}/rest/usergroup/${id}`, { + method: 'DELETE', + }); + + fetchClientHostname = async (macAddress: string): Promise => { + const devices = await this.getClientDevice(macAddress); + return devices.length === 1 ? (devices[0]?.hostname ?? null) : null; + }; + + applySpeedProfile = async ( + macAddress: string, + speedProfileId: string + ): Promise => { + const [device] = await this.getClientDevice(macAddress); + if (!device) { + return; + } + await this.request(`/api/s/${this.config.site}/rest/user/${device._id}`, { + body: JSON.stringify({ usergroup_id: speedProfileId }), + method: 'PUT', + }); + }; +} diff --git a/apps/chronos/src/modules/wifi/utils/unifi-controller.ts b/apps/chronos/src/modules/wifi/utils/unifi-controller.ts new file mode 100644 index 00000000..46e71260 --- /dev/null +++ b/apps/chronos/src/modules/wifi/utils/unifi-controller.ts @@ -0,0 +1,125 @@ +import { getLogger } from '@logtape/logtape'; +import type { + WifiController, + WifiSpeedProfile, + WifiSpeedProfileInput, +} from './controller'; +import { UnifiClient } from './unifi-api'; + +const logger = getLogger(['chronos', 'wifi', 'unifi-controller']); + +const toProfile = (profile: { + _id: string; + name: string; + qos_rate_max_down?: number; + qos_rate_max_up?: number; +}): WifiSpeedProfile => ({ + downloadSpeedMbps: + profile.qos_rate_max_down === undefined || profile.qos_rate_max_down === -1 + ? null + : Math.round(profile.qos_rate_max_down / 1000), + id: profile._id, + name: profile.name, + uploadSpeedMbps: + profile.qos_rate_max_up === undefined || profile.qos_rate_max_up === -1 + ? null + : Math.round(profile.qos_rate_max_up / 1000), +}); + +export class UnifiController implements WifiController { + readonly provider = 'unifi'; + private readonly client: UnifiClient; + + constructor(client: UnifiClient) { + this.client = client; + } + + fetchClientHostname(macAddress: string) { + return this.client.fetchClientHostname(macAddress); + } + + async updateClientName(macAddress: string, name: string): Promise { + try { + const devices = await this.client.getClientDevice(macAddress); + const device = devices[0]; + if (device) { + const newName = `${device.hostname ?? macAddress} - ${name}`; + await this.client.setClientName(device._id, newName); + logger.debug('Updated Unifi client name for {mac} to "{newName}"', { + mac: macAddress, + newName, + }); + } else { + logger.warn('Unifi client not found for MAC {mac}', { + mac: macAddress, + }); + } + } catch (error) { + logger.error('Error updating Unifi client name for {mac}: {error}', { + error, + mac: macAddress, + }); + } + } + + applySpeedProfile(macAddress: string, speedProfileId: string) { + return this.client.applySpeedProfile(macAddress, speedProfileId); + } + + async getSpeedProfiles(): Promise { + return (await this.client.getUserGroups()).map(toProfile); + } + + async getWlanDefaultSpeedProfile(ssid: string): Promise { + const wlans = await this.client.getWlanConf(); + const wlan = wlans.find((w) => w.name === ssid); + return wlan?.usergroup_id ?? null; + } + + async createSpeedProfile( + input: WifiSpeedProfileInput + ): Promise { + return toProfile( + await this.client.createUserGroup( + input.name, + input.downloadSpeedMbps, + input.uploadSpeedMbps + ) + ); + } + + async updateSpeedProfile( + id: string, + input: WifiSpeedProfileInput + ): Promise { + return toProfile( + await this.client.editUserGroup( + id, + 'default', + input.name, + input.downloadSpeedMbps, + input.uploadSpeedMbps + ) + ); + } + + deleteSpeedProfile(id: string) { + return this.client.deleteUserGroup(id); + } +} + +export const createUnifiController = (config: { + host: string; + insecureTls?: boolean; + password: string; + port: number; + username: string; +}): WifiController => + new UnifiController( + new UnifiClient({ + baseUrl: `https://${config.host}:${config.port}`, + ...(config.insecureTls ? { insecureTls: true } : {}), + password: config.password, + username: config.username, + }) + ); diff --git a/apps/chronos/src/router.ts b/apps/chronos/src/router.ts index 2c94e8a9..baafda9c 100644 --- a/apps/chronos/src/router.ts +++ b/apps/chronos/src/router.ts @@ -13,6 +13,7 @@ import { pingRouter } from '#modules/ping/_router'; import { rolesRouter } from '#modules/roles/_router'; import { timetableRouter } from '#modules/timetable/_router'; import { usersRouter } from '#modules/users/_router'; +import { wifiRouter } from '#modules/wifi/_router'; import { base } from '#orpc'; /** @@ -38,4 +39,5 @@ export const appRouter = base.use(rateLimit).router({ roles: rolesRouter, timetable: timetableRouter, users: usersRouter, + wifi: wifiRouter, }); diff --git a/apps/chronos/src/utils/authentication.ts b/apps/chronos/src/utils/authentication.ts index e1e412ce..ef1def84 100644 --- a/apps/chronos/src/utils/authentication.ts +++ b/apps/chronos/src/utils/authentication.ts @@ -12,6 +12,7 @@ import { user as userTable, } from '#database/schema/authentication'; import { teacher } from '#modules/timetable/schema'; +import { wifiUser } from '#modules/wifi/schema'; import { getUserPermissions } from '#utils/authorization'; import { createEntraIdTokenVerifier } from '#utils/entra-id-token'; import { env } from '#utils/environment'; @@ -103,6 +104,28 @@ const authOptions = { ) ) ); + + // Claim an unlinked legacy WiFi account only on an exact, + // case-insensitive full-email match. Matching the email's local + // part instead would let any user whose local part collides with + // an unclaimed wifi username take over that account, password + // included. + const [wifiAccount] = await db + .update(wifiUser) + .set({ createdBy: session.userId, userId: session.userId }) + .where( + and( + eq(sql`lower(${wifiUser.username})`, userEmail), + isNull(wifiUser.userId) + ) + ) + .returning({ id: wifiUser.id }); + if (wifiAccount) { + logger.info('Linked user {userId} to wifi account {wifiId}', { + userId: session.userId, + wifiId: wifiAccount.id, + }); + } } const fullName = linkedUser.name?.trim().toLowerCase(); @@ -143,7 +166,7 @@ const authOptions = { } } } catch (err) { - logger.error('Failed to link user to teacher', { + logger.error('Failed to link user to teacher or wifi user', { err, userId: session.userId, }); diff --git a/apps/chronos/src/utils/authorization.ts b/apps/chronos/src/utils/authorization.ts index 3bf64036..021df354 100644 --- a/apps/chronos/src/utils/authorization.ts +++ b/apps/chronos/src/utils/authorization.ts @@ -1,3 +1,4 @@ +import { permissions as permissionConstants } from '@filcdev/api/permissions'; import { getLogger } from '@logtape/logtape'; import { eq, inArray } from 'drizzle-orm'; import { db } from '#database'; @@ -135,6 +136,11 @@ export const initializeRBAC = async () => { .values([ { can: ['*'], name: 'admin' }, { can: [], name: 'user' }, + { can: [permissionConstants.wifiRead], name: 'wifi-viewer' }, + { + can: [permissionConstants.wifiRead, permissionConstants.wifiWrite], + name: 'wifi-admin', + }, ]) .onConflictDoNothing(); diff --git a/apps/chronos/src/utils/environment.ts b/apps/chronos/src/utils/environment.ts index 3a079ac2..62df31dd 100644 --- a/apps/chronos/src/utils/environment.ts +++ b/apps/chronos/src/utils/environment.ts @@ -28,6 +28,8 @@ const envShape = z.object({ CHRONOS_FCM_CREDENTIALS: z.string().optional(), CHRONOS_FCM_PROJECT_ID: z.string().optional(), + CHRONOS_FREERADIUS_IP: z.string().optional(), + CHRONOS_FREERADIUS_SHARED_SECRET: z.string().optional(), CHRONOS_KIOSK_WEATHER_LOCATION: z .string() .default('47.50535837979173,19.090123083749727'), @@ -86,6 +88,16 @@ const envShape = z.object({ z.array(z.url()).optional() ), CHRONOS_WEATHER_API_KEY: z.string().optional(), + CHRONOS_WIFI_CA_CERT_PATH: z.string().optional(), + CHRONOS_WIFI_CONTROLLER_PROVIDER: z.enum(['none', 'unifi']).default('none'), + CHRONOS_WIFI_ENABLED: boolean.default(false), + CHRONOS_WIFI_ENCRYPTION_SECRET: z.string().optional(), + CHRONOS_WIFI_SSID: z.string().optional(), + UNIFI_HOST: z.string().optional(), + UNIFI_INSECURE_TLS: boolean.default(false), + UNIFI_PASSWORD: z.string().optional(), + UNIFI_PORT: z.coerce.number().min(MIN_PORT).max(MAX_PORT).default(8443), + UNIFI_USERNAME: z.string().optional(), }); const envSchema = envShape.refine( diff --git a/apps/iris/public/locales/en/translation.json b/apps/iris/public/locales/en/translation.json index 106f3851..112a8fff 100644 --- a/apps/iris/public/locales/en/translation.json +++ b/apps/iris/public/locales/en/translation.json @@ -62,7 +62,9 @@ "edit": "Edit", "no": "No", "save": "Save", - "yes": "Yes" + "yes": "Yes", + "showPassword": "Show password", + "hidePassword": "Hide password" }, "login": { "byContinuing": "By continuing, you agree to our" @@ -89,7 +91,9 @@ "cohortSaveFailed": "Failed to save cohort", "cohortSkip": "Skip — I don't need a cohort", "complete": "All set!", - "completeDescription": "You're all set up and ready to go. Click Complete to start using the app." + "completeDescription": "You're all set up and ready to go. Click Complete to start using the app.", + "wifiDescription": "Set up your Wi-Fi account", + "wifiPrompt": "Create or review your Wi-Fi account to connect to the network." }, "cohort": { "fetchFailed": "Fetching cohorts failed", @@ -164,7 +168,8 @@ "bugReports": "Bug reports", "developer": "Developer", "kiosks": "Kiosks", - "navigator": "Navigator" + "navigator": "Navigator", + "wifi": "WiFi" }, "timetable": { "selectClass": "Select class", @@ -1033,5 +1038,230 @@ "y": "Y", "lastSeen": "Last seen" } + }, + "wifi": { + "accountReadyLater": "Account will be ready in a few minutes", + "setup": "Wi-Fi Setup", + "goToSetup": "Go to Wi-Fi Setup", + "title": "Wi‑Fi", + "description": "Manage your Wi‑Fi account and connection details.", + "account": "Account", + "username": "Username", + "networkName": "Network", + "note": "Notes", + "speedLimit": "Speed limit", + "download": "Download", + "upload": "Upload", + "source": "Source", + "devices": "Devices", + "accountBannedTitle": "Wi-Fi Access Blocked", + "accountBannedDescription": "Your account has been blocked from using the Wi-Fi network. If you think this is a mistake, please contact the administrator.", + "deviceBannedTitle": "Device Blocked", + "deviceBannedDescription": "One or more of your devices have been blocked from using the Wi-Fi network. If you think this is a mistake, please contact the administrator.", + "noDevices": "No Wi‑Fi devices are linked to your account yet.", + "active": "Active", + "blocked": "Blocked", + "never": "Never", + "unlimited": "Unlimited", + "unavailable": "Wi‑Fi is unavailable", + "loadError": "The Wi‑Fi status could not be loaded.", + "disabled": "Wi‑Fi is disabled", + "disabledDescription": "The Wi‑Fi module has been disabled for this environment.", + "setupRecommendation": "Recommended next step", + "setupRecommendationText": "Create or review your Wi‑Fi account to connect to {{ssid}}.", + "skipForNow": "Skip for now", + "noAccountYet": "No Wi‑Fi account is linked to your user yet. You can create one here to start connecting to the network.", + "createAccount": "Create Account", + "password": "Password", + "passwordPlaceholder": "Enter a password", + "createAccountSuccess": "Wi-Fi account created successfully", + "createAccountError": "Failed to create Wi-Fi account", + "changePassword": "Change Password", + "updatePassword": "Update Password", + "newPassword": "New Password", + "newPasswordPlaceholder": "Enter a new password", + "confirmPassword": "Confirm Password", + "confirmPasswordPlaceholder": "Confirm the new password", + "passwordMismatch": "Passwords do not match", + "passwordRequired": "Password is required", + "passwordTooShort": "Password must be at least 8 characters", + "passwordChangeSuccess": "Password changed successfully. You will need to re-authenticate all your devices.", + "passwordChangeError": "Failed to change password", + "passwordChangeWarning": "Re-authentication Required", + "passwordChangeWarningDescription": "After changing your password, you will need to re-authenticate every device connected to the Wi-Fi network with the new password.", + "passwordSecurityWarning": "Important Security Notice", + "passwordSecurityWarningDescription": "The Wi-Fi password is stored in a reversible form for network compatibility. Do not reuse any password you use for other services like email or cloud accounts.", + "editDevice": "Edit device name", + "certificate": "CA Certificate", + "downloadCertificate": "Download Certificate", + "downloading": "Downloading...", + "certificateDescription": "Download the Wi-Fi network's CA certificate for advanced network configuration.", + "certificateDownloadSuccess": "Certificate downloaded successfully", + "certificateDownloadError": "Failed to download certificate", + "deviceUpdateSuccess": "Device name updated", + "deviceUpdateError": "Failed to update device name", + "override": "Manual override", + "role": "Role default", + "wlan_default": "WLAN default", + "sourceOverride": "Manual override", + "sourceRole": "Role default", + "sourceWlan_default": "WLAN default" + }, + "wifiAdminDashboard": { + "title": "WiFi Dashboard", + "description": "Overview of WiFi network usage and authentications", + "totalUsers": "Total Users", + "totalDevices": "Total Devices", + "activeDevices": "Active Devices (24h)", + "authentications": "Authentications (last 7 days)", + "accepted": "Accepted", + "rejected": "Rejected", + "loadError": "Unable to load statistics", + "loadErrorMessage": "Something went wrong while fetching the dashboard.", + "showingPage": "Page {{page}}", + "authLogs": "Authentication Logs", + "searchLogs": "Search username, MAC, NAS IP...", + "filterResult": "Filter by result", + "allResults": "All results", + "successOnly": "Success only", + "failuresOnly": "Failures only", + "time": "Time", + "username": "Username", + "macAddress": "MAC Address", + "nas": "NAS IP/MAC", + "result": "Result", + "success": "Success", + "failed": "Failed", + "noLogs": "No logs found." + }, + "wifiAdminUsers": { + "createError": "Failed to create user", + "updateError": "Failed to update user", + "createDeviceSuccess": "Device created successfully", + "updateDeviceSuccess": "Device updated successfully", + "updateDeviceError": "Failed to update device", + "deleteDeviceSuccess": "Device deleted successfully", + "deleteDeviceError": "Failed to delete device", + "title": "Accounts & Devices", + "description": "Manage WiFi users, their devices, and MAC bans", + "searchPlaceholder": "Search users or devices...", + "refresh": "Refresh", + "addUser": "Add User", + "addDevice": "Add Device / Ban", + "orphanDevices": "Orphan Devices & MAC Bans", + "orphanDevicesDescription": "Devices not linked to any specific user.", + "username": "Username", + "speedProfile": "Speed Profile", + "comment": "Comment", + "lastActive": "Last Active", + "status": "Status", + "actions": "Actions", + "active": "Active", + "banned": "Banned", + "bannedDevice": "Banned Device", + "devices": "Devices", + "deviceMac": "MAC Address", + "deviceNickname": "Nickname", + "nicknameHint": "Visible to the user", + "commentHint": "Only visible to admins", + "reportedHostname": "Reported Hostname", + "loadError": "Unable to load accounts and devices", + "filters": "Filters", + "filterBanned": "Banned only", + "filterInactive": "Inactive (>45 days)", + "filterActive": "Active (<45 days)", + "filterMultipleMacs": "Shared MACs", + "filterManual": "Manually created users", + "createdByHint": "Created by: {{name}}", + "createdByDeletedAdmin": "Created by: Deleted Admin", + "minDevices": "Min devices", + "macUsedMultipleTimes": "This MAC is shared by multiple users", + "inactiveWarning": "Inactive for more than 45 days", + "editUser": "Edit User", + "deleteUser": "Delete User", + "editDevice": "Edit Device", + "deleteDevice": "Delete Device", + "noUsersFound": "No users found.", + "noDevicesFound": "No devices found.", + "createSuccess": "User created", + "updateSuccess": "User updated", + "deleteSuccess": "User deleted", + "deleteError": "Failed to delete user", + "deviceCreateSuccess": "Device created", + "deviceUpdateSuccess": "Device updated", + "deviceDeleteSuccess": "Device deleted", + "deviceDeleteError": "Failed to delete device", + "deleteConfirmTitle": "Delete User", + "deleteConfirmDescription": "Are you sure you want to delete this user? Their connected devices will also be removed.", + "deleteDeviceConfirmTitle": "Delete Device", + "deleteDeviceConfirmDescription": "Are you sure you want to delete this device?", + "createDeviceError": "Failed to create device", + "deleteUserWarning": "Are you sure you want to delete this user? Deleting a user does not equate to banning them, as they can simply reregister their account.", + "deleteDeviceWarning": "Are you sure you want to delete this device? Deleting a device does not equate to banning it, as it can simply rejoin the network.", + "leaveBlankToKeep": "Leave blank to keep the current password" + }, + "wifiAdminNas": { + "createError": "Failed to create NAS", + "updateError": "Failed to update NAS", + "title": "NAS Devices", + "description": "Manage authorized Network Access Servers", + "searchPlaceholder": "Search NAS devices...", + "refresh": "Refresh", + "addNas": "Add NAS", + "ipAddress": "IP Address", + "macAddress": "MAC Address", + "comment": "Comment", + "actions": "Actions", + "editNas": "Edit NAS", + "deleteNas": "Delete NAS", + "noNasFound": "No NAS devices found.", + "loadError": "Unable to load NAS devices", + "createSuccess": "NAS created", + "updateSuccess": "NAS updated", + "deleteSuccess": "NAS deleted", + "deleteError": "Failed to delete NAS", + "deleteConfirmTitle": "Delete NAS", + "deleteConfirmDescription": "Are you sure you want to delete this NAS?" + }, + "wifiAdminProfiles": { + "title": "Speed Profiles", + "description": "Manage speed limits and role mappings", + "refresh": "Refresh", + "addProfile": "Create Profile", + "addMapping": "Add Role Mapping", + "name": "Name", + "download": "Download (Mbps)", + "upload": "Upload (Mbps)", + "priority": "Priority", + "roleName": "Role", + "speedProfile": "Profile", + "actions": "Actions", + "editProfile": "Edit Profile", + "deleteProfile": "Delete Profile", + "editMapping": "Edit Mapping", + "deleteMapping": "Delete Mapping", + "noProfilesFound": "No profiles found.", + "noMappingsFound": "No mappings found.", + "loadError": "Unable to load profiles", + "createSuccess": "Profile created", + "updateSuccess": "Profile updated", + "deleteSuccess": "Profile deleted", + "deleteError": "Failed to delete profile", + "createError": "Failed to create profile", + "updateError": "Failed to update profile", + "createMappingSuccess": "Mapping created", + "updateMappingSuccess": "Mapping updated", + "deleteMappingSuccess": "Mapping deleted", + "deleteMappingError": "Failed to delete mapping", + "createMappingError": "Failed to create mapping", + "updateMappingError": "Failed to update mapping", + "unlimited": "Unlimited", + "speedProfilesSection": "UniFi Speed Profiles", + "roleMappingsSection": "Role Mappings", + "all": "All Profiles", + "none": "None (Default)", + "deleteConfirmTitle": "Delete Profile", + "deleteMappingConfirmTitle": "Delete Mapping", + "unlimitedHint": "-1 for unlimited" } } diff --git a/apps/iris/public/locales/hu/translation.json b/apps/iris/public/locales/hu/translation.json index 752f3bd9..a8c57bbb 100644 --- a/apps/iris/public/locales/hu/translation.json +++ b/apps/iris/public/locales/hu/translation.json @@ -97,7 +97,9 @@ "cohortSaveFailed": "A csoportot nem sikerült elmenteni", "cohortSkip": "Kihagyás — nincs szükségem csoportra", "complete": "Minden kész!", - "completeDescription": "Minden be van állítva, és készen állsz. Kattints a Befejezés gombra az alkalmazás használatának megkezdéséhez." + "completeDescription": "Minden be van állítva, és készen állsz. Kattints a Befejezés gombra az alkalmazás használatának megkezdéséhez.", + "wifiDescription": "Állítsd be a Wi-Fi fiókodat", + "wifiPrompt": "Hozd létre vagy ellenőrizd a Wi-Fi fiókodat a hálózathoz való csatlakozáshoz." }, "doorlock": { "dashboard": "Vezérlőpult", @@ -148,7 +150,8 @@ "bugReports": "Hibajelentések", "developer": "Fejlesztő", "kiosks": "Kijelzők", - "navigator": "Navigátor" + "navigator": "Navigátor", + "wifi": "WiFi" }, "timetable": { "selectClass": "Osztály kiválasztása", @@ -269,7 +272,9 @@ "edit": "Szerkesztés", "no": "Nem", "save": "Mentés", - "yes": "Igen" + "yes": "Igen", + "showPassword": "Jelszó megjelenítése", + "hidePassword": "Jelszó elrejtése" }, "navigation": { "title": "Navigáció", @@ -1033,5 +1038,230 @@ "y": "Y", "lastSeen": "Utoljára látva" } + }, + "wifi": { + "accountReadyLater": "A fiók hamarosan elkészül", + "setup": "Wi-Fi beállítás", + "goToSetup": "Ugrás a Wi-Fi beállításokhoz", + "title": "Wi‑Fi", + "description": "Kezeld a Wi‑Fi fiókod és kapcsolódási adataidat.", + "account": "Fiók", + "username": "Felhasználónév", + "networkName": "Hálózat", + "note": "Megjegyzés", + "speedLimit": "Sebességkorlátozás", + "download": "Letöltés", + "upload": "Feltöltés", + "source": "Forrás", + "devices": "Eszközök", + "accountBannedTitle": "Wi-Fi hozzáférés blokkolva", + "accountBannedDescription": "A fiókod blokkolva lett a Wi-Fi hálózaton. Ha úgy gondolod, hogy ez egy hiba, kérlek vedd fel a kapcsolatot a rendszergazdával.", + "deviceBannedTitle": "Eszköz blokkolva", + "deviceBannedDescription": "Egy vagy több eszközöd blokkolva lett a Wi-Fi hálózaton. Ha úgy gondolod, hogy ez egy hiba, kérlek vedd fel a kapcsolatot a rendszergazdával.", + "noDevices": "Még nincs Wi‑Fi eszköz hozzárendelve a fiókodhoz.", + "active": "Aktív", + "blocked": "Blokkolva", + "never": "Soha", + "unlimited": "Korlátlan", + "unavailable": "A Wi‑Fi nem elérhető", + "loadError": "A Wi‑Fi állapot betöltése sikertelen.", + "disabled": "A Wi‑Fi le van tiltva", + "disabledDescription": "A Wi‑Fi modul le van tiltva ebben a környezetben.", + "setupRecommendation": "Ajánlott következő lépés", + "setupRecommendationText": "Hozd létre vagy ellenőrizd a Wi‑Fi fiókodat a(z) {{ssid}} hálózathoz való csatlakozáshoz.", + "skipForNow": "Kihagyom most", + "noAccountYet": "Még nincs Wi‑Fi fiók hozzárendelve a felhasználóhoz. Itt létrehozhatod a hálózathoz való csatlakozás megkezdéséhez.", + "createAccount": "Fiók létrehozása", + "password": "Jelszó", + "passwordPlaceholder": "Adjon meg egy jelszót", + "createAccountSuccess": "Wi-Fi fiók sikeresen létrehozva", + "createAccountError": "A Wi-Fi fiók létrehozása sikertelen", + "changePassword": "Jelszó megváltoztatása", + "updatePassword": "Jelszó frissítése", + "newPassword": "Új jelszó", + "newPasswordPlaceholder": "Adjon meg egy új jelszót", + "confirmPassword": "Jelszó megerősítése", + "confirmPasswordPlaceholder": "Erősítse meg az új jelszót", + "passwordMismatch": "A jelszavak nem egyeznek", + "passwordRequired": "Jelszó szükséges", + "passwordTooShort": "A jelszó legalább 8 karakter hosszú legyen", + "passwordChangeSuccess": "Jelszó sikeresen megváltoztatva. Az összes eszközt újra hitelesítenie kell.", + "passwordChangeError": "A jelszó megváltoztatása sikertelen", + "passwordChangeWarning": "Újrahitelesítés szükséges", + "passwordChangeWarningDescription": "A jelszó megváltoztatása után az összes Wi-Fi hálózathoz csatlakoztatott eszközt újra hitelesítenie kell az új jelszóval.", + "passwordSecurityWarning": "Fontos biztonsági figyelmeztetés", + "passwordSecurityWarningDescription": "A Wi-Fi jelszó a hálózati kompatibilitás érdekében visszafejthető formában van tárolva. Ne használd újra azt a jelszót, amelyet más szolgáltatásoknál (e-mail, felhőfiók) használsz.", + "editDevice": "Eszköz nevének szerkesztése", + "certificate": "CA tanúsítvány", + "downloadCertificate": "Tanúsítvány letöltése", + "downloading": "Letöltés...", + "certificateDescription": "Töltse le a Wi-Fi hálózat CA tanúsítványát speciális hálózati konfigurációhoz.", + "certificateDownloadSuccess": "Tanúsítvány sikeresen letöltve", + "certificateDownloadError": "A tanúsítvány letöltése sikertelen", + "deviceUpdateSuccess": "Eszköz neve frissítve", + "deviceUpdateError": "Nem sikerült frissíteni az eszköz nevét", + "override": "Kézi felülírás", + "role": "Szerepkör alapértelmezett", + "wlan_default": "WLAN alapértelmezett", + "sourceOverride": "Kézi felülírás", + "sourceRole": "Szerepkör alapértelmezett", + "sourceWlan_default": "WLAN alapértelmezett" + }, + "wifiAdminDashboard": { + "title": "WiFi Irányítópult", + "description": "WiFi hálózat használatának és hitelesítéseinek áttekintése", + "totalUsers": "Összes Felhasználó", + "totalDevices": "Összes Eszköz", + "activeDevices": "Aktív Eszközök (24ó)", + "authentications": "Hitelesítések (utolsó 7 nap)", + "accepted": "Elfogadva", + "rejected": "Elutasítva", + "loadError": "Statisztikák betöltése sikertelen", + "loadErrorMessage": "Hiba történt az irányítópult adatainak lekérésekor.", + "showingPage": "{{page}}. oldal", + "authLogs": "Hitelesítési napló", + "searchLogs": "Keresés felhasználónév, MAC, NAS IP...", + "filterResult": "Szűrés eredmény szerint", + "allResults": "Minden eredmény", + "successOnly": "Csak sikeres", + "failuresOnly": "Csak sikertelen", + "time": "Idő", + "username": "Felhasználónév", + "macAddress": "MAC cím", + "nas": "NAS IP/MAC", + "result": "Eredmény", + "success": "Sikeres", + "failed": "Sikertelen", + "noLogs": "Nem található naplóbejegyzés." + }, + "wifiAdminUsers": { + "createError": "Felhasználó létrehozása sikertelen", + "updateError": "Felhasználó frissítése sikertelen", + "createDeviceSuccess": "Eszköz sikeresen létrehozva", + "updateDeviceSuccess": "Eszköz sikeresen frissítve", + "updateDeviceError": "Eszköz frissítése sikertelen", + "deleteDeviceSuccess": "Eszköz sikeresen törölve", + "deleteDeviceError": "Eszköz törlése sikertelen", + "title": "Fiókok és Eszközök", + "description": "WiFi felhasználók, eszközeik és MAC tiltások kezelése", + "searchPlaceholder": "Felhasználók vagy eszközök keresése...", + "refresh": "Frissítés", + "addUser": "Felhasználó hozzáadása", + "addDevice": "Eszköz / Tiltás", + "orphanDevices": "Gazdátlan Eszközök és Tiltások", + "orphanDevicesDescription": "Egyetlen felhasználóhoz sem tartozó eszközök.", + "username": "Felhasználónév", + "speedProfile": "Sebesség Profil", + "comment": "Megjegyzés", + "lastActive": "Utoljára Aktív", + "status": "Állapot", + "actions": "Műveletek", + "active": "Aktív", + "banned": "Letiltva", + "bannedDevice": "Letiltott Eszköz", + "devices": "Eszközök", + "deviceMac": "MAC Cím", + "deviceNickname": "Becenév", + "nicknameHint": "A felhasználó számára látható", + "commentHint": "Csak adminok számára látható", + "reportedHostname": "Jelentett Eszköznév", + "loadError": "Fiókok és eszközök betöltése sikertelen", + "filters": "Szűrők", + "filterBanned": "Csak letiltottak", + "filterInactive": "Inaktív (>45 napja)", + "filterActive": "Aktív (<45 napja)", + "filterMultipleMacs": "Megosztott MAC", + "filterManual": "Kézzel létrehozott fiókok", + "createdByHint": "Létrehozta: {{name}}", + "createdByDeletedAdmin": "Létrehozta: Törölt Admin", + "minDevices": "Min eszköz", + "macUsedMultipleTimes": "Ezt a MAC címet több felhasználó is használja", + "inactiveWarning": "Több mint 45 napja inaktív", + "editUser": "Felhasználó szerkesztése", + "deleteUser": "Felhasználó törlése", + "editDevice": "Eszköz szerkesztése", + "deleteDevice": "Eszköz törlése", + "noUsersFound": "Nem található felhasználó.", + "noDevicesFound": "Nem található eszköz.", + "createSuccess": "Felhasználó létrehozva", + "updateSuccess": "Felhasználó frissítve", + "deleteSuccess": "Felhasználó törölve", + "deleteError": "Nem sikerült törölni a felhasználót", + "deviceCreateSuccess": "Eszköz létrehozva", + "deviceUpdateSuccess": "Eszköz frissítve", + "deviceDeleteSuccess": "Eszköz törölve", + "deviceDeleteError": "Nem sikerült törölni az eszközt", + "deleteConfirmTitle": "Felhasználó törlése", + "deleteConfirmDescription": "Biztosan törölni szeretnéd ezt a felhasználót? A hozzá tartozó eszközök is törlésre kerülnek.", + "deleteDeviceConfirmTitle": "Eszköz törlése", + "deleteDeviceConfirmDescription": "Biztosan törölni szeretnéd ezt az eszközt?", + "createDeviceError": "Eszköz hozzáadása sikertelen", + "deleteUserWarning": "Biztosan törölni szeretnéd ezt a felhasználót? A felhasználó törlése nem egyenlő a kitiltásával, mivel egyszerűen újra regisztrálhatja a fiókját.", + "deleteDeviceWarning": "Biztosan törölni szeretnéd ezt az eszközt? Az eszköz törlése nem egyenlő a kitiltásával, mivel egyszerűen újra csatlakozhat a hálózathoz.", + "leaveBlankToKeep": "Hagyd üresen, ha meg akarod tartani a jelenlegi jelszót" + }, + "wifiAdminNas": { + "createError": "NAS létrehozása sikertelen", + "updateError": "NAS frissítése sikertelen", + "title": "NAS Eszközök", + "description": "Engedélyezett hálózati hozzáférési szerverek kezelése", + "searchPlaceholder": "NAS eszközök keresése...", + "refresh": "Frissítés", + "addNas": "NAS hozzáadása", + "ipAddress": "IP Cím", + "macAddress": "MAC Cím", + "comment": "Megjegyzés", + "actions": "Műveletek", + "editNas": "NAS szerkesztése", + "deleteNas": "NAS törlése", + "noNasFound": "Nem található NAS eszköz.", + "loadError": "NAS eszközök betöltése sikertelen", + "createSuccess": "NAS létrehozva", + "updateSuccess": "NAS frissítve", + "deleteSuccess": "NAS törölve", + "deleteError": "Nem sikerült törölni a NAS-t", + "deleteConfirmTitle": "NAS törlése", + "deleteConfirmDescription": "Biztosan törölni szeretnéd ezt a NAS-t?" + }, + "wifiAdminProfiles": { + "title": "Sebesség Profilok", + "description": "Sebességkorlátok és szerepkör leképezések kezelése", + "refresh": "Frissítés", + "addProfile": "Profil létrehozása", + "addMapping": "Szerepkör leképezés", + "name": "Név", + "download": "Letöltés (Mbps)", + "upload": "Feltöltés (Mbps)", + "priority": "Prioritás", + "roleName": "Szerepkör", + "speedProfile": "Profil", + "actions": "Műveletek", + "editProfile": "Profil szerkesztése", + "deleteProfile": "Profil törlése", + "editMapping": "Leképezés szerkesztése", + "deleteMapping": "Leképezés törlése", + "noProfilesFound": "Nem található profil.", + "noMappingsFound": "Nem található leképezés.", + "loadError": "Profilok betöltése sikertelen", + "createSuccess": "Profil létrehozva", + "updateSuccess": "Profil frissítve", + "deleteSuccess": "Profil törölve", + "deleteError": "Nem sikerült törölni a profilt", + "createError": "Profil létrehozása sikertelen", + "updateError": "Profil frissítése sikertelen", + "createMappingSuccess": "Leképezés létrehozva", + "updateMappingSuccess": "Leképezés frissítve", + "deleteMappingSuccess": "Leképezés törölve", + "deleteMappingError": "Nem sikerült törölni a leképezést", + "createMappingError": "Leképezés létrehozása sikertelen", + "updateMappingError": "Leképezés frissítése sikertelen", + "unlimited": "Korlátlan", + "speedProfilesSection": "UniFi Sebesség Profilok", + "roleMappingsSection": "Szerepkör Leképezések", + "all": "Összes Profil", + "none": "Nincs (Alapértelmezett)", + "deleteConfirmTitle": "Profil törlése", + "deleteMappingConfirmTitle": "Leképezés törlése", + "unlimitedHint": "-1 a korlátlan sávszélességhez" } } diff --git a/apps/iris/src/components/admin/sidebar.tsx b/apps/iris/src/components/admin/sidebar.tsx index c3088d0f..fa2701e5 100644 --- a/apps/iris/src/components/admin/sidebar.tsx +++ b/apps/iris/src/components/admin/sidebar.tsx @@ -38,10 +38,12 @@ import { Shield, UserRound, Users, + Wifi, } from 'lucide-react'; import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { hasPermission } from '@/hooks/use-has-permission'; +import { useWifiStatus } from '@/hooks/wifi'; type MenuIcon = typeof List; @@ -63,6 +65,8 @@ export function AdminSidebar() { const navigate = useNavigate(); const { isMobile, setOpenMobile } = useSidebar(); const userPermissions = session?.user?.permissions; + const wifiStatus = useWifiStatus(); + const wifiEnabled = wifiStatus.data?.enabled === true; const handleNavigate = (url: string) => { navigate({ to: url }); @@ -257,6 +261,39 @@ export function AdminSidebar() { ], label: t('admin.kiosks'), }, + ...(wifiEnabled + ? [ + { + items: [ + { + icon: Wifi, + permission: permissions.wifiRead, + title: t('wifiAdminDashboard.title'), + url: '/admin/wifi', + }, + { + icon: Users, + permission: permissions.wifiRead, + title: t('wifiAdminUsers.title'), + url: '/admin/wifi/users', + }, + { + icon: MonitorSmartphone, + permission: permissions.wifiRead, + title: t('wifiAdminNas.title'), + url: '/admin/wifi/nas', + }, + { + icon: ArrowUpDown, + permission: permissions.wifiRead, + title: t('wifiAdminProfiles.title'), + url: '/admin/wifi/speed-profiles', + }, + ], + label: t('admin.wifi'), + }, + ] + : []), ...(import.meta.env.MODE === 'development' ? [ { @@ -273,7 +310,7 @@ export function AdminSidebar() { ] : []), ], - [t] + [t, wifiEnabled] ); const visibleCategories = useMemo( diff --git a/apps/iris/src/components/navbar.tsx b/apps/iris/src/components/navbar.tsx index b730f50b..5d155ded 100644 --- a/apps/iris/src/components/navbar.tsx +++ b/apps/iris/src/components/navbar.tsx @@ -28,6 +28,7 @@ import { LogOut, Menu, UserCog, + Wifi, X, } from 'lucide-react'; import type { ElementType, ReactNode } from 'react'; @@ -40,6 +41,7 @@ import { ADMIN_UI_PERMISSIONS, useHasPermission, } from '@/hooks/use-has-permission'; +import { useWifiStatus } from '@/hooks/wifi'; import type { FileRoutesByTo } from '@/routeTree.gen'; // Both dialogs are a click away and cost real weight (TanStack Form, the date @@ -73,6 +75,18 @@ const NAV_ITEMS: NavItem[] = [ { adminOnly: true, icon: UserCog, labelKey: 'adminDashboard', to: '/admin' }, ]; +/** + * The WiFi entry is only meaningful while the module is enabled, which only + * the server knows — so the list is derived per render rather than kept as a + * module-level constant. + */ +const getNavItems = (showWifi: boolean): NavItem[] => [ + ...NAV_ITEMS, + ...(showWifi + ? [{ icon: Wifi, labelKey: 'wifi.title', to: '/wifi' } satisfies NavItem] + : []), +]; + export function Navbar({ children, showLinks = true, @@ -90,6 +104,10 @@ export function Navbar({ ADMIN_UI_PERMISSIONS, data?.user?.permissions ); + const wifiStatus = useWifiStatus(); + const navItems = getNavItems( + Boolean(data?.user) && wifiStatus.data?.enabled === true + ); return ( <> @@ -132,6 +150,7 @@ export function Navbar({ {data && showLinks && ( )} @@ -269,22 +288,22 @@ export function Navbar({
{mobileMenuOpen && (
- {NAV_ITEMS.filter( - (item) => !item.adminOnly || canSeeAdminUi - ).map((item) => ( - - ))} + {navItems + .filter((item) => !item.adminOnly || canSeeAdminUi) + .map((item) => ( + + ))}
)}
@@ -297,7 +316,13 @@ export function Navbar({ ); } -function NavLinks({ userPermissions }: { userPermissions?: string[] }) { +function NavLinks({ + items, + userPermissions, +}: { + items: NavItem[]; + userPermissions?: string[]; +}) { const navigate = useNavigate(); const { t } = useTranslation(); @@ -305,8 +330,9 @@ function NavLinks({ userPermissions }: { userPermissions?: string[] }) { return (
- {NAV_ITEMS.filter((item) => !item.adminOnly || canSeeAdminUi).map( - (item) => ( + {items + .filter((item) => !item.adminOnly || canSeeAdminUi) + .map((item) => ( - ) - )} + ))}
); } diff --git a/apps/iris/src/components/util/relative-time.tsx b/apps/iris/src/components/util/relative-time.tsx new file mode 100644 index 00000000..7c26ac40 --- /dev/null +++ b/apps/iris/src/components/util/relative-time.tsx @@ -0,0 +1,48 @@ +import { cn } from '@filcdev/ui/lib/utils'; +import dayjs from 'dayjs'; +import 'dayjs/locale/hu'; +import relativeTime from 'dayjs/plugin/relativeTime'; +import { useTranslation } from 'react-i18next'; +import { formatLocalizedDate } from '@/utils/date-locale'; + +dayjs.extend(relativeTime); + +/** Anything older than this is highlighted, so stale accounts stand out. */ +const STALE_DAYS = 45; + +type RelativeTimeProps = { + date: string | Date | null | undefined; + className?: string; +}; + +/** + * "3 days ago", with the exact timestamp in the tooltip. Null and unparsable + * values render as `-` rather than an epoch date. + */ +export function RelativeTime({ date, className }: RelativeTimeProps) { + const { i18n } = useTranslation(); + const language = i18n.language; + + if (!date) { + return -; + } + + const parsed = dayjs(date); + if (!parsed.isValid()) { + return -; + } + + const isStale = dayjs().diff(parsed, 'day') > STALE_DAYS; + + return ( + + {parsed.locale(language).fromNow()} + + ); +} diff --git a/apps/iris/src/components/wifi/wifi-auth-chart.tsx b/apps/iris/src/components/wifi/wifi-auth-chart.tsx new file mode 100644 index 00000000..106555fc --- /dev/null +++ b/apps/iris/src/components/wifi/wifi-auth-chart.tsx @@ -0,0 +1,101 @@ +import type { WifiStatsOverview } from '@filcdev/api/domains/wifi/stats'; +import { + type ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from '@filcdev/ui/components/chart'; +import dayjs from 'dayjs'; +import 'dayjs/locale/hu'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts'; + +const SERIES_DAYS = 7; + +const chartConfig = { + accepted: { color: 'var(--primary)', label: 'Accepted' }, + rejected: { color: 'var(--destructive)', label: 'Rejected' }, +} satisfies ChartConfig; + +/** + * Accepted vs rejected authentications over the last week. The server returns + * only the days that have attempts, so the series is zero-filled to a fixed + * seven-day window here. + */ +export function WifiAuthChart({ + data, +}: { + data: WifiStatsOverview['authSeries']; +}) { + const { i18n, t } = useTranslation(); + const language = i18n.language; + + const chartData = useMemo(() => { + const byDate = new Map( + data.map((entry) => [ + entry.date, + { accepted: entry.accepted, rejected: entry.rejected }, + ]) + ); + + return Array.from({ length: SERIES_DAYS }, (_, index) => { + const date = dayjs() + .subtract(SERIES_DAYS - 1 - index, 'day') + .format('YYYY-MM-DD'); + const entry = byDate.get(date) ?? { accepted: 0, rejected: 0 }; + + return { + accepted: entry.accepted, + date, + label: dayjs(date).locale(language).format('MMM D'), + rejected: entry.rejected, + }; + }); + }, [data, language]); + + return ( + + + + + + } + labelFormatter={(_, payload) => payload?.[0]?.payload.date ?? ''} + /> + + + + + ); +} diff --git a/apps/iris/src/components/wifi/wifi-dialogs.tsx b/apps/iris/src/components/wifi/wifi-dialogs.tsx new file mode 100644 index 00000000..c2480217 --- /dev/null +++ b/apps/iris/src/components/wifi/wifi-dialogs.tsx @@ -0,0 +1,753 @@ +import { Button } from '@filcdev/ui/components/button'; +import { Checkbox } from '@filcdev/ui/components/checkbox'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@filcdev/ui/components/dialog'; +import { Field, FieldError, FieldLabel } from '@filcdev/ui/components/field'; +import { Input } from '@filcdev/ui/components/input'; +import { PasswordInput } from '@filcdev/ui/components/password-input'; +import { + Select, + SelectTrigger, + SelectValue, +} from '@filcdev/ui/components/select'; +import { useForm, useStore } from '@tanstack/react-form'; +import { useId, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { BaseDialogProps } from '@/components/admin/admin.types'; +import { useRoles } from '@/hooks/admin-users'; +import { + useCreateWifiDevice, + useCreateWifiNas, + useCreateWifiRoleProfile, + useCreateWifiSpeedProfile, + useCreateWifiUser, + useUpdateWifiDevice, + useUpdateWifiNas, + useUpdateWifiRoleProfile, + useUpdateWifiSpeedProfile, + useUpdateWifiUser, + useWifiSpeedProfiles, + type WifiDevice, + type WifiNas, + type WifiRoleSpeedProfile, + type WifiSpeedProfile, + type WifiUser, +} from '@/hooks/wifi-admin'; +import { + wifiDeviceFormSchema, + wifiNasFormSchema, + wifiRoleProfileFormSchema, + wifiSpeedProfileFormSchema, + wifiUserFormValidator, +} from '@/utils/form-schemas'; + +/** `-1` is the controller's "unlimited"; the API takes it as-is. */ +const UNLIMITED = -1; + +/** Canonical `aa:bb:cc:dd:ee:ff` for an input that may be unseparated. */ +const formatMac = (mac: string | null | undefined): string => + mac + ? (mac + .replace(/[^0-9a-fA-F]/g, '') + .match(/.{1,2}/g) + ?.join(':') ?? mac) + : ''; + +export function WifiUserDialog({ + user, + open, + onOpenChange, +}: BaseDialogProps & { user?: WifiUser }) { + const { t } = useTranslation(); + const create = useCreateWifiUser({ onSaved: () => onOpenChange(false) }); + const update = useUpdateWifiUser({ onSaved: () => onOpenChange(false) }); + const isEditing = !!user; + const bannedId = useId(); + // One stable schema per mode: TanStack Form takes a single schema, and a + // ternary between two shapes is not assignable to it. + const userValidator = useMemo( + () => wifiUserFormValidator(isEditing), + [isEditing] + ); + + const profilesQuery = useWifiSpeedProfiles(); + const profileItems = [ + { label: t('wifiAdminProfiles.none'), value: 'none' }, + ...(profilesQuery.data?.speedProfiles ?? []).map((profile) => ({ + label: profile.name, + value: profile.id, + })), + ]; + + const form = useForm({ + defaultValues: { + banned: user?.banned ?? false, + comment: user?.comment ?? '', + password: '', + speedProfileId: user?.speedProfileId ?? null, + username: user?.username ?? '', + }, + onSubmit: ({ value }) => { + const payload = { + ...value, + // The update schema reads `''` as "keep the current password". + password: value.password === '' ? undefined : value.password, + }; + if (isEditing) { + update.mutate({ ...payload, id: user.id }); + } else { + create.mutate({ + ...payload, + password: payload.password ?? '', + }); + } + }, + validators: { + onChange: userValidator, + onSubmit: userValidator, + }, + }); + + const isSubmitting = useStore(form.store, (state) => state.isSubmitting); + const canSubmit = useStore(form.store, (state) => state.canSubmit); + + return ( + + + + + {isEditing + ? t('wifiAdminUsers.editUser') + : t('wifiAdminUsers.addUser')} + + +
{ + event.preventDefault(); + event.stopPropagation(); + form.handleSubmit(); + }} + > + + {(field) => ( + + {t('wifiAdminUsers.username')} + field.handleChange(event.target.value)} + value={field.state.value} + /> + + + )} + + + + {(field) => ( + + {t('wifi.password')} + field.handleChange(event.target.value)} + placeholder={ + isEditing ? t('wifiAdminUsers.leaveBlankToKeep') : '' + } + showPasswordLabel={t('common.showPassword')} + value={field.state.value} + /> + + + )} + + + + {(field) => ( + + {t('wifiAdminUsers.comment')} +

+ {t('wifiAdminUsers.commentHint')} +

+ field.handleChange(event.target.value)} + value={field.state.value ?? ''} + /> + +
+ )} +
+ + + {(field) => ( + + {t('wifiAdminUsers.speedProfile')} + + + + )} + + + {isEditing && ( + + {(field) => ( +
+ + field.handleChange(checked === true) + } + /> + +
+ )} +
+ )} + + + + + +
+
+
+ ); +} + +export function WifiDeviceDialog({ + device, + wifiUserId, + open, + onOpenChange, +}: BaseDialogProps & { device?: WifiDevice; wifiUserId?: string | null }) { + const { t } = useTranslation(); + const create = useCreateWifiDevice({ onSaved: () => onOpenChange(false) }); + const update = useUpdateWifiDevice({ onSaved: () => onOpenChange(false) }); + const isEditing = !!device; + const bannedId = useId(); + + const form = useForm({ + defaultValues: { + adminNotes: device?.adminNotes ?? '', + banned: device?.banned ?? false, + macAddress: formatMac(device?.macAddress), + nickname: device?.nickname ?? '', + wifiUserId: device?.wifiUserId ?? wifiUserId ?? null, + }, + onSubmit: ({ value }) => { + if (isEditing) { + update.mutate({ ...value, id: device.id }); + } else { + create.mutate(value); + } + }, + validators: { + onChange: wifiDeviceFormSchema, + onSubmit: wifiDeviceFormSchema, + }, + }); + + const isSubmitting = useStore(form.store, (state) => state.isSubmitting); + const canSubmit = useStore(form.store, (state) => state.canSubmit); + + return ( + + + + + {isEditing + ? t('wifiAdminUsers.editDevice') + : t('wifiAdminUsers.addDevice')} + + +
{ + event.preventDefault(); + event.stopPropagation(); + form.handleSubmit(); + }} + > + + {(field) => ( + + {t('wifiAdminUsers.deviceMac')} + field.handleChange(event.target.value)} + placeholder="00:11:22:33:44:55" + value={field.state.value} + /> + + + )} + + + + {(field) => ( + + {t('wifiAdminUsers.deviceNickname')} +

+ {t('wifiAdminUsers.nicknameHint')} +

+ field.handleChange(event.target.value)} + value={field.state.value ?? ''} + /> + +
+ )} +
+ + + {(field) => ( + + {t('wifiAdminUsers.comment')} +

+ {t('wifiAdminUsers.commentHint')} +

+ field.handleChange(event.target.value)} + value={field.state.value ?? ''} + /> + +
+ )} +
+ + + {(field) => ( +
+ + field.handleChange(checked === true) + } + /> + +
+ )} +
+ + + + + +
+
+
+ ); +} + +export function WifiNasDialog({ + nas, + open, + onOpenChange, +}: BaseDialogProps & { nas?: WifiNas }) { + const { t } = useTranslation(); + const create = useCreateWifiNas({ onSaved: () => onOpenChange(false) }); + const update = useUpdateWifiNas({ onSaved: () => onOpenChange(false) }); + const isEditing = !!nas; + + const form = useForm({ + defaultValues: { + comment: nas?.comment ?? '', + ipAddress: nas?.ipAddress ?? '', + macAddress: formatMac(nas?.macAddress), + }, + onSubmit: ({ value }) => { + if (isEditing) { + update.mutate({ ...value, id: nas.id }); + } else { + create.mutate(value); + } + }, + validators: { + onChange: wifiNasFormSchema, + onSubmit: wifiNasFormSchema, + }, + }); + + const isSubmitting = useStore(form.store, (state) => state.isSubmitting); + const canSubmit = useStore(form.store, (state) => state.canSubmit); + + return ( + + + + + {isEditing ? t('wifiAdminNas.editNas') : t('wifiAdminNas.addNas')} + + +
{ + event.preventDefault(); + event.stopPropagation(); + form.handleSubmit(); + }} + > + + {(field) => ( + + {t('wifiAdminNas.ipAddress')} + field.handleChange(event.target.value)} + placeholder="192.168.1.100" + value={field.state.value} + /> + + + )} + + + + {(field) => ( + + {t('wifiAdminNas.macAddress')} + field.handleChange(event.target.value)} + placeholder="00:11:22:33:44:55" + value={field.state.value} + /> + + + )} + + + + {(field) => ( + + {t('wifiAdminNas.comment')} + field.handleChange(event.target.value)} + value={field.state.value ?? ''} + /> + + + )} + + + + + + +
+
+
+ ); +} + +export function WifiSpeedProfileDialog({ + profile, + open, + onOpenChange, +}: BaseDialogProps & { profile?: WifiSpeedProfile }) { + const { t } = useTranslation(); + const create = useCreateWifiSpeedProfile({ + onSaved: () => onOpenChange(false), + }); + const update = useUpdateWifiSpeedProfile({ + onSaved: () => onOpenChange(false), + }); + const isEditing = !!profile; + + const form = useForm({ + defaultValues: { + downloadSpeedMbps: profile?.downloadSpeedMbps ?? UNLIMITED, + name: profile?.name ?? '', + uploadSpeedMbps: profile?.uploadSpeedMbps ?? UNLIMITED, + }, + onSubmit: ({ value }) => { + if (isEditing) { + update.mutate({ ...value, id: profile.id }); + } else { + create.mutate(value); + } + }, + validators: { + onChange: wifiSpeedProfileFormSchema, + onSubmit: wifiSpeedProfileFormSchema, + }, + }); + + const isSubmitting = useStore(form.store, (state) => state.isSubmitting); + const canSubmit = useStore(form.store, (state) => state.canSubmit); + + return ( + + + + + {isEditing + ? t('wifiAdminProfiles.editProfile') + : t('wifiAdminProfiles.addProfile')} + + +
{ + event.preventDefault(); + event.stopPropagation(); + form.handleSubmit(); + }} + > + + {(field) => ( + + {t('wifiAdminProfiles.name')} + field.handleChange(event.target.value)} + value={field.state.value} + /> + + + )} + + + + {(field) => ( + + {t('wifiAdminProfiles.download')} + + field.handleChange( + Number.parseInt(event.target.value, 10) || UNLIMITED + ) + } + type="number" + value={field.state.value} + /> +

+ {t('wifiAdminProfiles.unlimitedHint')} +

+ +
+ )} +
+ + + {(field) => ( + + {t('wifiAdminProfiles.upload')} + + field.handleChange( + Number.parseInt(event.target.value, 10) || UNLIMITED + ) + } + type="number" + value={field.state.value} + /> +

+ {t('wifiAdminProfiles.unlimitedHint')} +

+ +
+ )} +
+ + + + + +
+
+
+ ); +} + +export function WifiRoleProfileDialog({ + mapping, + open, + onOpenChange, +}: BaseDialogProps & { mapping?: WifiRoleSpeedProfile }) { + const { t } = useTranslation(); + const create = useCreateWifiRoleProfile({ + onSaved: () => onOpenChange(false), + }); + const update = useUpdateWifiRoleProfile({ + onSaved: () => onOpenChange(false), + }); + const isEditing = !!mapping; + + const rolesQuery = useRoles(); + const roleItems = (rolesQuery.data?.roles ?? []).map((role) => ({ + label: role.name, + value: role.name, + })); + + const profilesQuery = useWifiSpeedProfiles(); + const profileItems = (profilesQuery.data?.speedProfiles ?? []).map( + (profile) => ({ label: profile.name, value: profile.id }) + ); + + const form = useForm({ + defaultValues: { + priority: mapping?.priority ?? 0, + roleName: mapping?.roleName ?? '', + speedProfileId: mapping?.speedProfileId ?? '', + }, + onSubmit: ({ value }) => { + if (isEditing) { + update.mutate({ ...value, id: mapping.roleName }); + } else { + create.mutate(value); + } + }, + validators: { + onChange: wifiRoleProfileFormSchema, + onSubmit: wifiRoleProfileFormSchema, + }, + }); + + const isSubmitting = useStore(form.store, (state) => state.isSubmitting); + const canSubmit = useStore(form.store, (state) => state.canSubmit); + + return ( + + + + + {isEditing + ? t('wifiAdminProfiles.editMapping') + : t('wifiAdminProfiles.addMapping')} + + +
{ + event.preventDefault(); + event.stopPropagation(); + form.handleSubmit(); + }} + > + + {(field) => ( + + {t('wifiAdminProfiles.roleName')} + + + + )} + + + + {(field) => ( + + {t('wifiAdminProfiles.speedProfile')} + + + + )} + + + + {(field) => ( + + {t('wifiAdminProfiles.priority')} + + field.handleChange( + Number.parseInt(event.target.value, 10) || 0 + ) + } + type="number" + value={field.state.value} + /> + + + )} + + + + + + +
+
+
+ ); +} diff --git a/apps/iris/src/components/wifi/wifi-filters.tsx b/apps/iris/src/components/wifi/wifi-filters.tsx new file mode 100644 index 00000000..006fbcf5 --- /dev/null +++ b/apps/iris/src/components/wifi/wifi-filters.tsx @@ -0,0 +1,201 @@ +import { Button } from '@filcdev/ui/components/button'; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuPortal, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from '@filcdev/ui/components/dropdown-menu'; +import { Input } from '@filcdev/ui/components/input'; +import { Filter } from 'lucide-react'; +import type { ChangeEvent } from 'react'; +import { useId } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useWifiSpeedProfiles } from '@/hooks/wifi-admin'; + +export type WifiFilterState = { + speedProfileId: string | null; + bannedOnly: boolean; + inactiveOnly: boolean; + activeOnly: boolean; + sharedMacsOnly: boolean; + manualOnly: boolean; + minDevices: number; +}; + +export const defaultWifiFilterState: WifiFilterState = { + activeOnly: false, + bannedOnly: false, + inactiveOnly: false, + manualOnly: false, + minDevices: 0, + sharedMacsOnly: false, + speedProfileId: null, +}; + +type WifiFiltersProps = { + filters: WifiFilterState; + onChange: (filters: WifiFilterState) => void; +}; + +/** A filter counts as active when it would actually narrow the list. */ +const isActiveFilter = ([key, value]: [string, unknown]): boolean => { + if (key === 'minDevices') { + return (value as number) > 0; + } + if (key === 'speedProfileId') { + return value !== null; + } + return value === true; +}; + +export function WifiFilters({ filters, onChange }: WifiFiltersProps) { + const { t } = useTranslation(); + const minDevicesId = useId(); + const profilesQuery = useWifiSpeedProfiles(); + const profiles = profilesQuery.data?.speedProfiles ?? []; + + const handleToggle = (key: keyof WifiFilterState) => { + onChange({ ...filters, [key]: !filters[key] }); + }; + + const handleMinDevicesChange = (event: ChangeEvent) => { + const value = Number.parseInt(event.target.value, 10); + onChange({ ...filters, minDevices: Number.isNaN(value) ? 0 : value }); + }; + + const activeFiltersCount = + Object.entries(filters).filter(isActiveFilter).length; + + return ( + + + + + {t('wifiAdminUsers.filters')} + + {activeFiltersCount > 0 && ( + + {activeFiltersCount} + + )} + + } + /> + + + {t('wifiAdminUsers.filters')} + + + + handleToggle('bannedOnly')} + > + {t('wifiAdminUsers.filterBanned')} + + + + onChange({ + ...filters, + activeOnly: false, // Mutually exclusive + inactiveOnly: !filters.inactiveOnly, + }) + } + > + {t('wifiAdminUsers.filterInactive')} + + + + onChange({ + ...filters, + activeOnly: !filters.activeOnly, + inactiveOnly: false, // Mutually exclusive + }) + } + > + {t('wifiAdminUsers.filterActive')} + + + handleToggle('sharedMacsOnly')} + > + {t('wifiAdminUsers.filterMultipleMacs')} + + + handleToggle('manualOnly')} + > + {t('wifiAdminUsers.filterManual')} + + + +
+ + +
+ + + + + {t('wifiAdminUsers.speedProfile')} + + + + + onChange({ + ...filters, + speedProfileId: value === 'all' ? null : value, + }) + } + value={filters.speedProfileId ?? 'all'} + > + + {t('wifiAdminProfiles.all')} + + + {t('wifiAdminProfiles.none')} + + {profiles.map((profile) => ( + + {profile.name} + + ))} + + + + +
+
+ ); +} diff --git a/apps/iris/src/hooks/wifi-admin.ts b/apps/iris/src/hooks/wifi-admin.ts new file mode 100644 index 00000000..c24050de --- /dev/null +++ b/apps/iris/src/hooks/wifi-admin.ts @@ -0,0 +1,367 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; +import { type api, orpc } from '@/utils/orpc'; + +/** Options accepted by every mutation hook: react to a successful save. */ +export type MutationCallbacks = { + /** Called after success toast + cache invalidation; use to close dialogs. */ + onSaved?: () => void; +}; + +/** + * Query inputs are the procedures' *wire* input types, not the parsed output + * types: `wifiAuthLogListQuerySchema.result` is `'true' | 'false'` on the wire + * and a boolean after parsing, and the client takes the former. + */ +export type WifiListQuery = Parameters[0]; +export type WifiDeviceListQuery = Parameters[0]; +export type WifiAuthLogListQuery = Parameters[0]; + +type UsersData = Awaited>; +type DevicesData = Awaited>; +type LogsData = Awaited>; +type NasData = Awaited>; +type SpeedProfilesData = Awaited< + ReturnType +>; +type RoleProfilesData = Awaited< + ReturnType +>; + +export type WifiUser = UsersData['users'][number]; +export type WifiDevice = DevicesData['devices'][number]; +export type WifiAuthLog = LogsData['logs'][number]; +export type WifiNas = NasData['nas'][number]; +export type WifiSpeedProfile = SpeedProfilesData['speedProfiles'][number]; +export type WifiRoleSpeedProfile = + RoleProfilesData['roleSpeedProfiles'][number]; +export type WifiStatsOverview = Awaited< + ReturnType +>; + +export type WifiUserPayload = Parameters[0]; +export type WifiDevicePayload = Parameters[0]; +export type WifiNasPayload = Parameters[0]; +export type WifiSpeedProfilePayload = Parameters< + typeof api.wifi.speedProfiles.create +>[0]; +export type WifiRoleProfilePayload = Parameters< + typeof api.wifi.roleSpeedProfiles.create +>[0]; + +/** Account, device, active-device and auth statistics. */ +export function useWifiAdminStatsOverview() { + return useQuery(orpc.wifi.stats.overview.queryOptions()); +} + +/** WiFi accounts, newest last. */ +export function useWifiUsers(input: WifiListQuery) { + return useQuery({ ...orpc.wifi.users.list.queryOptions({ input }) }); +} + +/** Devices, optionally filtered by owner; `wifiUserId: null` means unowned. */ +export function useWifiDevices(input: WifiDeviceListQuery) { + return useQuery({ ...orpc.wifi.devices.list.queryOptions({ input }) }); +} + +/** Every authorized NAS. */ +export function useWifiNas() { + return useQuery(orpc.wifi.nas.list.queryOptions()); +} + +/** Speed profiles mirrored from the UniFi controller. */ +export function useWifiSpeedProfiles() { + return useQuery(orpc.wifi.speedProfiles.list.queryOptions()); +} + +/** Role → speed-profile mappings. */ +export function useWifiRoleProfiles() { + return useQuery(orpc.wifi.roleSpeedProfiles.list.queryOptions()); +} + +/** Auth attempts, newest first. */ +export function useWifiAuthLogs(input: WifiAuthLogListQuery) { + return useQuery({ ...orpc.wifi.authLogs.list.queryOptions({ input }) }); +} + +/** + * A user write changes the account *and* the devices the admin pages group + * under it, so both lists are invalidated. + */ +const invalidateUsersAndDevices = ( + queryClient: ReturnType +): void => { + queryClient.invalidateQueries({ queryKey: orpc.wifi.users.list.key() }); + queryClient.invalidateQueries({ queryKey: orpc.wifi.devices.list.key() }); +}; + +export function useCreateWifiUser({ onSaved }: MutationCallbacks = {}) { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + return useMutation( + orpc.wifi.users.create.mutationOptions({ + onError: (error) => { + toast.error(error.message || t('wifiAdminUsers.createError')); + }, + onSuccess: () => { + invalidateUsersAndDevices(queryClient); + toast.success(t('wifiAdminUsers.createSuccess')); + onSaved?.(); + }, + }) + ); +} + +export function useUpdateWifiUser({ onSaved }: MutationCallbacks = {}) { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + return useMutation( + orpc.wifi.users.update.mutationOptions({ + onError: (error) => { + toast.error(error.message || t('wifiAdminUsers.updateError')); + }, + onSuccess: () => { + invalidateUsersAndDevices(queryClient); + toast.success(t('wifiAdminUsers.updateSuccess')); + onSaved?.(); + }, + }) + ); +} + +export function useDeleteWifiUser({ onSaved }: MutationCallbacks = {}) { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + return useMutation( + orpc.wifi.users.delete.mutationOptions({ + onError: (error) => { + toast.error(error.message || t('wifiAdminUsers.deleteError')); + }, + onSuccess: () => { + invalidateUsersAndDevices(queryClient); + toast.success(t('wifiAdminUsers.deleteSuccess')); + onSaved?.(); + }, + }) + ); +} + +export function useCreateWifiDevice({ onSaved }: MutationCallbacks = {}) { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + return useMutation( + orpc.wifi.devices.create.mutationOptions({ + onError: (error) => { + toast.error(error.message || t('wifiAdminUsers.createDeviceError')); + }, + onSuccess: () => { + invalidateUsersAndDevices(queryClient); + toast.success(t('wifiAdminUsers.createDeviceSuccess')); + onSaved?.(); + }, + }) + ); +} + +export function useUpdateWifiDevice({ onSaved }: MutationCallbacks = {}) { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + return useMutation( + orpc.wifi.devices.update.mutationOptions({ + onError: (error) => { + toast.error(error.message || t('wifiAdminUsers.updateDeviceError')); + }, + onSuccess: () => { + invalidateUsersAndDevices(queryClient); + toast.success(t('wifiAdminUsers.updateDeviceSuccess')); + onSaved?.(); + }, + }) + ); +} + +export function useDeleteWifiDevice({ onSaved }: MutationCallbacks = {}) { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + return useMutation( + orpc.wifi.devices.delete.mutationOptions({ + onError: (error) => { + toast.error(error.message || t('wifiAdminUsers.deleteDeviceError')); + }, + onSuccess: () => { + invalidateUsersAndDevices(queryClient); + toast.success(t('wifiAdminUsers.deleteDeviceSuccess')); + onSaved?.(); + }, + }) + ); +} + +export function useCreateWifiNas({ onSaved }: MutationCallbacks = {}) { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + return useMutation( + orpc.wifi.nas.create.mutationOptions({ + onError: (error) => { + toast.error(error.message || t('wifiAdminNas.createError')); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: orpc.wifi.nas.list.key() }); + toast.success(t('wifiAdminNas.createSuccess')); + onSaved?.(); + }, + }) + ); +} + +export function useUpdateWifiNas({ onSaved }: MutationCallbacks = {}) { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + return useMutation( + orpc.wifi.nas.update.mutationOptions({ + onError: (error) => { + toast.error(error.message || t('wifiAdminNas.updateError')); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: orpc.wifi.nas.list.key() }); + toast.success(t('wifiAdminNas.updateSuccess')); + onSaved?.(); + }, + }) + ); +} + +export function useDeleteWifiNas({ onSaved }: MutationCallbacks = {}) { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + return useMutation( + orpc.wifi.nas.delete.mutationOptions({ + onError: (error) => { + toast.error(error.message || t('wifiAdminNas.deleteError')); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: orpc.wifi.nas.list.key() }); + toast.success(t('wifiAdminNas.deleteSuccess')); + onSaved?.(); + }, + }) + ); +} + +export function useCreateWifiSpeedProfile({ onSaved }: MutationCallbacks = {}) { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + return useMutation( + orpc.wifi.speedProfiles.create.mutationOptions({ + onError: (error) => { + toast.error(error.message || t('wifiAdminProfiles.createError')); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: orpc.wifi.speedProfiles.list.key(), + }); + toast.success(t('wifiAdminProfiles.createSuccess')); + onSaved?.(); + }, + }) + ); +} + +export function useUpdateWifiSpeedProfile({ onSaved }: MutationCallbacks = {}) { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + return useMutation( + orpc.wifi.speedProfiles.update.mutationOptions({ + onError: (error) => { + toast.error(error.message || t('wifiAdminProfiles.updateError')); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: orpc.wifi.speedProfiles.list.key(), + }); + toast.success(t('wifiAdminProfiles.updateSuccess')); + onSaved?.(); + }, + }) + ); +} + +export function useDeleteWifiSpeedProfile({ onSaved }: MutationCallbacks = {}) { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + return useMutation( + orpc.wifi.speedProfiles.delete.mutationOptions({ + onError: (error) => { + toast.error(error.message || t('wifiAdminProfiles.deleteError')); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: orpc.wifi.speedProfiles.list.key(), + }); + queryClient.invalidateQueries({ + queryKey: orpc.wifi.roleSpeedProfiles.list.key(), + }); + toast.success(t('wifiAdminProfiles.deleteSuccess')); + onSaved?.(); + }, + }) + ); +} + +export function useCreateWifiRoleProfile({ onSaved }: MutationCallbacks = {}) { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + return useMutation( + orpc.wifi.roleSpeedProfiles.create.mutationOptions({ + onError: (error) => { + toast.error(error.message || t('wifiAdminProfiles.createMappingError')); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: orpc.wifi.roleSpeedProfiles.list.key(), + }); + toast.success(t('wifiAdminProfiles.createMappingSuccess')); + onSaved?.(); + }, + }) + ); +} + +export function useUpdateWifiRoleProfile({ onSaved }: MutationCallbacks = {}) { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + return useMutation( + orpc.wifi.roleSpeedProfiles.update.mutationOptions({ + onError: (error) => { + toast.error(error.message || t('wifiAdminProfiles.updateMappingError')); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: orpc.wifi.roleSpeedProfiles.list.key(), + }); + toast.success(t('wifiAdminProfiles.updateMappingSuccess')); + onSaved?.(); + }, + }) + ); +} + +export function useDeleteWifiRoleProfile({ onSaved }: MutationCallbacks = {}) { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + return useMutation( + orpc.wifi.roleSpeedProfiles.delete.mutationOptions({ + onError: (error) => { + toast.error(error.message || t('wifiAdminProfiles.deleteMappingError')); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: orpc.wifi.roleSpeedProfiles.list.key(), + }); + toast.success(t('wifiAdminProfiles.deleteMappingSuccess')); + onSaved?.(); + }, + }) + ); +} diff --git a/apps/iris/src/hooks/wifi.ts b/apps/iris/src/hooks/wifi.ts new file mode 100644 index 00000000..f9a1bf42 --- /dev/null +++ b/apps/iris/src/hooks/wifi.ts @@ -0,0 +1,126 @@ +import { isDefinedError } from '@orpc/client'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; +import { type api, orpc } from '@/utils/orpc'; + +/** Options accepted by every mutation hook: react to a successful save. */ +export type MutationCallbacks = { + /** Called after success toast + cache invalidation; use to close dialogs. */ + onSaved?: () => void; +}; + +export type WifiStatus = Awaited>; + +type SelfData = Awaited>; +export type WifiSelfData = SelfData['wifi']; +export type WifiSelfDevice = WifiSelfData['devices'][number]; + +/** + * Whether the module is on, and the SSID it authenticates. Reachable without a + * session and without the module being enabled — it is what the apps read to + * decide whether to render any WiFi surface at all. + */ +export function useWifiStatus() { + return useQuery(orpc.wifi.status.queryOptions()); +} + +/** + * The signed-in user's WiFi account. A 404 is the "no account yet" state the + * page renders as a setup form, so it must not be retried. + */ +export function useWifiSelf(enabled = true) { + return useQuery({ + ...orpc.wifi.self.get.queryOptions(), + enabled, + retry: (failureCount, error) => + !(isDefinedError(error) && error.code === 'NOT_FOUND') && + failureCount < 2, + }); +} + +const invalidateSelf = ( + queryClient: ReturnType +): void => { + queryClient.invalidateQueries({ queryKey: orpc.wifi.self.get.key() }); +}; + +/** Create the signed-in user's WiFi account. */ +export function useCreateWifiAccount({ onSaved }: MutationCallbacks = {}) { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + return useMutation( + orpc.wifi.self.create.mutationOptions({ + onError: (error) => { + toast.error(error.message || t('wifi.createAccountError')); + }, + onSuccess: () => { + invalidateSelf(queryClient); + toast.success(t('wifi.createAccountSuccess')); + onSaved?.(); + }, + }) + ); +} + +/** Change the signed-in user's WiFi password. */ +export function useUpdateWifiPassword({ onSaved }: MutationCallbacks = {}) { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + return useMutation( + orpc.wifi.self.changePassword.mutationOptions({ + onError: (error) => { + toast.error(error.message || t('wifi.passwordChangeError')); + }, + onSuccess: () => { + invalidateSelf(queryClient); + toast.success(t('wifi.passwordChangeSuccess')); + onSaved?.(); + }, + }) + ); +} + +/** Rename one of the signed-in user's devices. */ +export function useUpdateWifiDevice({ onSaved }: MutationCallbacks = {}) { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + return useMutation( + orpc.wifi.self.updateDevice.mutationOptions({ + onError: (error) => { + toast.error(error.message || t('wifi.deviceUpdateError')); + }, + onSuccess: () => { + invalidateSelf(queryClient); + toast.success(t('wifi.deviceUpdateSuccess')); + onSaved?.(); + }, + }) + ); +} + +/** Download the network CA certificate the account needs to join. */ +export function useDownloadWifiCertificate({ + onSaved, +}: MutationCallbacks = {}) { + const { t } = useTranslation(); + return useMutation( + orpc.wifi.self.certificate.mutationOptions({ + onError: (error) => { + toast.error(error.message || t('wifi.certificateDownloadError')); + }, + onSuccess: (file) => { + const url = URL.createObjectURL(file); + const anchor = document.createElement('a'); + anchor.download = 'wifi-ca.pem'; + anchor.href = url; + document.body.append(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); + toast.success(t('wifi.certificateDownloadSuccess')); + onSaved?.(); + }, + }) + ); +} diff --git a/apps/iris/src/routeTree.gen.ts b/apps/iris/src/routeTree.gen.ts index 6423a079..aca72272 100644 --- a/apps/iris/src/routeTree.gen.ts +++ b/apps/iris/src/routeTree.gen.ts @@ -13,6 +13,7 @@ import { Route as PrivateRouteRouteImport } from './routes/_private/route' import { Route as PublicRouteRouteImport } from './routes/_public/route' import { Route as PrivateAdminRouteRouteImport } from './routes/_private/admin/route' import { Route as PrivateSettingsRouteImport } from './routes/_private/settings' +import { Route as PrivateWifiRouteImport } from './routes/_private/wifi' import { Route as PublicIndexRouteImport } from './routes/_public/index' import { Route as PublicPolicyRouteImport } from './routes/_public/policy' import { Route as PublicSubsRouteImport } from './routes/_public/subs' @@ -47,6 +48,10 @@ import { Route as PrivateAdminTimetableManageRouteImport } from './routes/_priva import { Route as PrivateAdminTimetableMovedLessonsRouteImport } from './routes/_private/admin/timetable/moved-lessons' import { Route as PrivateAdminTimetableSubstitutionsRouteImport } from './routes/_private/admin/timetable/substitutions' import { Route as PrivateAdminTimetableTeachersRouteImport } from './routes/_private/admin/timetable/teachers' +import { Route as PrivateAdminWifiIndexRouteImport } from './routes/_private/admin/wifi/index' +import { Route as PrivateAdminWifiNasRouteImport } from './routes/_private/admin/wifi/nas' +import { Route as PrivateAdminWifiSpeedProfilesRouteImport } from './routes/_private/admin/wifi/speed-profiles' +import { Route as PrivateAdminWifiUsersRouteImport } from './routes/_private/admin/wifi/users' const PrivateRouteRoute = PrivateRouteRouteImport.update({ id: '/_private', @@ -66,6 +71,11 @@ const PrivateSettingsRoute = PrivateSettingsRouteImport.update({ path: '/settings', getParentRoute: () => PrivateRouteRoute, } as any) +const PrivateWifiRoute = PrivateWifiRouteImport.update({ + id: '/wifi', + path: '/wifi', + getParentRoute: () => PrivateRouteRoute, +} as any) const PublicIndexRoute = PublicIndexRouteImport.update({ id: '/', path: '/', @@ -257,11 +267,33 @@ const PrivateAdminTimetableTeachersRoute = path: '/timetable/teachers', getParentRoute: () => PrivateAdminRouteRoute, } as any) +const PrivateAdminWifiIndexRoute = PrivateAdminWifiIndexRouteImport.update({ + id: '/wifi/', + path: '/wifi/', + getParentRoute: () => PrivateAdminRouteRoute, +} as any) +const PrivateAdminWifiNasRoute = PrivateAdminWifiNasRouteImport.update({ + id: '/wifi/nas', + path: '/wifi/nas', + getParentRoute: () => PrivateAdminRouteRoute, +} as any) +const PrivateAdminWifiSpeedProfilesRoute = + PrivateAdminWifiSpeedProfilesRouteImport.update({ + id: '/wifi/speed-profiles', + path: '/wifi/speed-profiles', + getParentRoute: () => PrivateAdminRouteRoute, + } as any) +const PrivateAdminWifiUsersRoute = PrivateAdminWifiUsersRouteImport.update({ + id: '/wifi/users', + path: '/wifi/users', + getParentRoute: () => PrivateAdminRouteRoute, +} as any) export interface FileRoutesByFullPath { '/': typeof PublicIndexRoute '/admin': typeof PrivateAdminRouteRouteWithChildren '/settings': typeof PrivateSettingsRoute + '/wifi': typeof PrivateWifiRoute '/policy': typeof PublicPolicyRoute '/subs': typeof PublicSubsRoute '/unsubscribe': typeof PublicUnsubscribeRoute @@ -293,13 +325,18 @@ export interface FileRoutesByFullPath { '/admin/timetable/moved-lessons': typeof PrivateAdminTimetableMovedLessonsRoute '/admin/timetable/substitutions': typeof PrivateAdminTimetableSubstitutionsRoute '/admin/timetable/teachers': typeof PrivateAdminTimetableTeachersRoute + '/admin/wifi/nas': typeof PrivateAdminWifiNasRoute + '/admin/wifi/speed-profiles': typeof PrivateAdminWifiSpeedProfilesRoute + '/admin/wifi/users': typeof PrivateAdminWifiUsersRoute '/admin/doorlock/': typeof PrivateAdminDoorlockIndexRoute '/admin/navigator/': typeof PrivateAdminNavigatorIndexRoute + '/admin/wifi/': typeof PrivateAdminWifiIndexRoute } export interface FileRoutesByTo { '/': typeof PublicIndexRoute '/admin': typeof PrivateAdminRouteRouteWithChildren '/settings': typeof PrivateSettingsRoute + '/wifi': typeof PrivateWifiRoute '/policy': typeof PublicPolicyRoute '/subs': typeof PublicSubsRoute '/unsubscribe': typeof PublicUnsubscribeRoute @@ -331,8 +368,12 @@ export interface FileRoutesByTo { '/admin/timetable/moved-lessons': typeof PrivateAdminTimetableMovedLessonsRoute '/admin/timetable/substitutions': typeof PrivateAdminTimetableSubstitutionsRoute '/admin/timetable/teachers': typeof PrivateAdminTimetableTeachersRoute + '/admin/wifi/nas': typeof PrivateAdminWifiNasRoute + '/admin/wifi/speed-profiles': typeof PrivateAdminWifiSpeedProfilesRoute + '/admin/wifi/users': typeof PrivateAdminWifiUsersRoute '/admin/doorlock': typeof PrivateAdminDoorlockIndexRoute '/admin/navigator': typeof PrivateAdminNavigatorIndexRoute + '/admin/wifi': typeof PrivateAdminWifiIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -340,6 +381,7 @@ export interface FileRoutesById { '/_public': typeof PublicRouteRouteWithChildren '/_private/admin': typeof PrivateAdminRouteRouteWithChildren '/_private/settings': typeof PrivateSettingsRoute + '/_private/wifi': typeof PrivateWifiRoute '/_public/policy': typeof PublicPolicyRoute '/_public/subs': typeof PublicSubsRoute '/_public/unsubscribe': typeof PublicUnsubscribeRoute @@ -372,8 +414,12 @@ export interface FileRoutesById { '/_private/admin/timetable/moved-lessons': typeof PrivateAdminTimetableMovedLessonsRoute '/_private/admin/timetable/substitutions': typeof PrivateAdminTimetableSubstitutionsRoute '/_private/admin/timetable/teachers': typeof PrivateAdminTimetableTeachersRoute + '/_private/admin/wifi/nas': typeof PrivateAdminWifiNasRoute + '/_private/admin/wifi/speed-profiles': typeof PrivateAdminWifiSpeedProfilesRoute + '/_private/admin/wifi/users': typeof PrivateAdminWifiUsersRoute '/_private/admin/doorlock/': typeof PrivateAdminDoorlockIndexRoute '/_private/admin/navigator/': typeof PrivateAdminNavigatorIndexRoute + '/_private/admin/wifi/': typeof PrivateAdminWifiIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -381,6 +427,7 @@ export interface FileRouteTypes { | '/' | '/admin' | '/settings' + | '/wifi' | '/policy' | '/subs' | '/unsubscribe' @@ -412,13 +459,18 @@ export interface FileRouteTypes { | '/admin/timetable/moved-lessons' | '/admin/timetable/substitutions' | '/admin/timetable/teachers' + | '/admin/wifi/nas' + | '/admin/wifi/speed-profiles' + | '/admin/wifi/users' | '/admin/doorlock/' | '/admin/navigator/' + | '/admin/wifi/' fileRoutesByTo: FileRoutesByTo to: | '/' | '/admin' | '/settings' + | '/wifi' | '/policy' | '/subs' | '/unsubscribe' @@ -450,14 +502,19 @@ export interface FileRouteTypes { | '/admin/timetable/moved-lessons' | '/admin/timetable/substitutions' | '/admin/timetable/teachers' + | '/admin/wifi/nas' + | '/admin/wifi/speed-profiles' + | '/admin/wifi/users' | '/admin/doorlock' | '/admin/navigator' + | '/admin/wifi' id: | '__root__' | '/_private' | '/_public' | '/_private/admin' | '/_private/settings' + | '/_private/wifi' | '/_public/policy' | '/_public/subs' | '/_public/unsubscribe' @@ -490,8 +547,12 @@ export interface FileRouteTypes { | '/_private/admin/timetable/moved-lessons' | '/_private/admin/timetable/substitutions' | '/_private/admin/timetable/teachers' + | '/_private/admin/wifi/nas' + | '/_private/admin/wifi/speed-profiles' + | '/_private/admin/wifi/users' | '/_private/admin/doorlock/' | '/_private/admin/navigator/' + | '/_private/admin/wifi/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -532,6 +593,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PrivateSettingsRouteImport parentRoute: typeof PrivateRouteRoute } + '/_private/wifi': { + id: '/_private/wifi' + path: '/wifi' + fullPath: '/wifi' + preLoaderRoute: typeof PrivateWifiRouteImport + parentRoute: typeof PrivateRouteRoute + } '/_public/': { id: '/_public/' path: '/' @@ -770,6 +838,34 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PrivateAdminTimetableTeachersRouteImport parentRoute: typeof PrivateAdminRouteRoute } + '/_private/admin/wifi/': { + id: '/_private/admin/wifi/' + path: '/wifi' + fullPath: '/admin/wifi/' + preLoaderRoute: typeof PrivateAdminWifiIndexRouteImport + parentRoute: typeof PrivateAdminRouteRoute + } + '/_private/admin/wifi/nas': { + id: '/_private/admin/wifi/nas' + path: '/wifi/nas' + fullPath: '/admin/wifi/nas' + preLoaderRoute: typeof PrivateAdminWifiNasRouteImport + parentRoute: typeof PrivateAdminRouteRoute + } + '/_private/admin/wifi/speed-profiles': { + id: '/_private/admin/wifi/speed-profiles' + path: '/wifi/speed-profiles' + fullPath: '/admin/wifi/speed-profiles' + preLoaderRoute: typeof PrivateAdminWifiSpeedProfilesRouteImport + parentRoute: typeof PrivateAdminRouteRoute + } + '/_private/admin/wifi/users': { + id: '/_private/admin/wifi/users' + path: '/wifi/users' + fullPath: '/admin/wifi/users' + preLoaderRoute: typeof PrivateAdminWifiUsersRouteImport + parentRoute: typeof PrivateAdminRouteRoute + } } } @@ -798,8 +894,12 @@ interface PrivateAdminRouteRouteChildren { PrivateAdminTimetableMovedLessonsRoute: typeof PrivateAdminTimetableMovedLessonsRoute PrivateAdminTimetableSubstitutionsRoute: typeof PrivateAdminTimetableSubstitutionsRoute PrivateAdminTimetableTeachersRoute: typeof PrivateAdminTimetableTeachersRoute + PrivateAdminWifiNasRoute: typeof PrivateAdminWifiNasRoute + PrivateAdminWifiSpeedProfilesRoute: typeof PrivateAdminWifiSpeedProfilesRoute + PrivateAdminWifiUsersRoute: typeof PrivateAdminWifiUsersRoute PrivateAdminDoorlockIndexRoute: typeof PrivateAdminDoorlockIndexRoute PrivateAdminNavigatorIndexRoute: typeof PrivateAdminNavigatorIndexRoute + PrivateAdminWifiIndexRoute: typeof PrivateAdminWifiIndexRoute } const PrivateAdminRouteRouteChildren: PrivateAdminRouteRouteChildren = { @@ -831,8 +931,12 @@ const PrivateAdminRouteRouteChildren: PrivateAdminRouteRouteChildren = { PrivateAdminTimetableSubstitutionsRoute: PrivateAdminTimetableSubstitutionsRoute, PrivateAdminTimetableTeachersRoute: PrivateAdminTimetableTeachersRoute, + PrivateAdminWifiNasRoute: PrivateAdminWifiNasRoute, + PrivateAdminWifiSpeedProfilesRoute: PrivateAdminWifiSpeedProfilesRoute, + PrivateAdminWifiUsersRoute: PrivateAdminWifiUsersRoute, PrivateAdminDoorlockIndexRoute: PrivateAdminDoorlockIndexRoute, PrivateAdminNavigatorIndexRoute: PrivateAdminNavigatorIndexRoute, + PrivateAdminWifiIndexRoute: PrivateAdminWifiIndexRoute, } const PrivateAdminRouteRouteWithChildren = @@ -841,12 +945,14 @@ const PrivateAdminRouteRouteWithChildren = interface PrivateRouteRouteChildren { PrivateAdminRouteRoute: typeof PrivateAdminRouteRouteWithChildren PrivateSettingsRoute: typeof PrivateSettingsRoute + PrivateWifiRoute: typeof PrivateWifiRoute PrivateCardsIndexRoute: typeof PrivateCardsIndexRoute } const PrivateRouteRouteChildren: PrivateRouteRouteChildren = { PrivateAdminRouteRoute: PrivateAdminRouteRouteWithChildren, PrivateSettingsRoute: PrivateSettingsRoute, + PrivateWifiRoute: PrivateWifiRoute, PrivateCardsIndexRoute: PrivateCardsIndexRoute, } diff --git a/apps/iris/src/routes/_private/admin/wifi/index.tsx b/apps/iris/src/routes/_private/admin/wifi/index.tsx new file mode 100644 index 00000000..5963cbc0 --- /dev/null +++ b/apps/iris/src/routes/_private/admin/wifi/index.tsx @@ -0,0 +1,340 @@ +import { permissions } from '@filcdev/api/permissions'; +import { + Alert, + AlertDescription, + AlertTitle, +} from '@filcdev/ui/components/alert'; +import { Badge } from '@filcdev/ui/components/badge'; +import { Button } from '@filcdev/ui/components/button'; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from '@filcdev/ui/components/card'; +import { Input } from '@filcdev/ui/components/input'; +import { + Select, + SelectTrigger, + SelectValue, +} from '@filcdev/ui/components/select'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@filcdev/ui/components/table'; +import { createFileRoute } from '@tanstack/react-router'; +import { + ChevronLeft, + ChevronRight, + Search, + Smartphone, + Users, + Wifi, +} from 'lucide-react'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PermissionGuard } from '@/components/util/permission-guard'; +import { QueryBoundary } from '@/components/util/query-boundary'; +import { RelativeTime } from '@/components/util/relative-time'; +import { WifiAuthChart } from '@/components/wifi/wifi-auth-chart'; +import { useWifiAdminStatsOverview, useWifiAuthLogs } from '@/hooks/wifi-admin'; +import { orpc, prefetch } from '@/utils/orpc'; + +const LOGS_PAGE_SIZE = 20; + +/** The wire enum is `'true' | 'false'`; the UI's tri-state maps onto it. */ +const RESULT_FILTERS = ['all', 'success', 'failure'] as const; + +type ResultFilter = (typeof RESULT_FILTERS)[number]; + +const resultFilterToWire = ( + filter: ResultFilter +): 'true' | 'false' | undefined => { + if (filter === 'all') { + return undefined; + } + return filter === 'success' ? 'true' : 'false'; +}; + +export const Route = createFileRoute('/_private/admin/wifi/')({ + component: () => ( + + + + ), + loader: ({ context }) => + prefetch(context.queryClient, orpc.wifi.stats.overview.queryOptions()), +}); + +function WifiDashboard() { + const { t } = useTranslation(); + const statsQuery = useWifiAdminStatsOverview(); + + return ( +
+
+

+ {t('wifiAdminDashboard.title')} +

+

+ {t('wifiAdminDashboard.description')} +

+
+ + ( + + {t('wifiAdminDashboard.loadError')} + + {message ?? t('wifiAdminDashboard.loadErrorMessage')} + + + )} + loading={ +
{t('common.loading')}
+ } + query={statsQuery} + > + {(stats) => ( + <> +
+ + + +
+ + + + {t('wifiAdminDashboard.authentications')} + + + + + + + )} +
+ + +
+ ); +} + +function StatCard({ + icon: Icon, + label, + value, +}: { + icon: typeof Users; + label: string; + value: number; +}) { + return ( + + + {label} + + + +
{value}
+
+
+ ); +} + +function WifiAuthLogs() { + const { t } = useTranslation(); + const [search, setSearch] = useState(''); + const [resultFilter, setResultFilter] = useState('all'); + const [page, setPage] = useState(0); + + const logsQuery = useWifiAuthLogs({ + limit: LOGS_PAGE_SIZE, + offset: page * LOGS_PAGE_SIZE, + result: resultFilterToWire(resultFilter), + search: search || undefined, + }); + + const logs = logsQuery.data?.logs ?? []; + const resultLabels: Record = { + all: t('wifiAdminDashboard.allResults'), + failure: t('wifiAdminDashboard.failuresOnly'), + success: t('wifiAdminDashboard.successOnly'), + }; + + return ( + + + {t('wifiAdminDashboard.authLogs')} + + +
+
+ + { + setSearch(event.target.value); + setPage(0); + }} + placeholder={t('wifiAdminDashboard.searchLogs')} + value={search} + /> +
+ +
+ +
+ + + + {t('wifiAdminDashboard.time')} + {t('wifiAdminDashboard.username')} + {t('wifiAdminDashboard.macAddress')} + {t('wifiAdminDashboard.nas')} + {t('wifiAdminDashboard.result')} + + + + {logsQuery.isLoading && ( + + + {t('common.loading')} + + + )} + {!logsQuery.isLoading && logs.length === 0 && ( + + + {t('wifiAdminDashboard.noLogs')} + + + )} + {!logsQuery.isLoading && + logs.map((log) => ( + + + + + +
+ {log.username} + {log.userComment && ( + + {log.userComment} + + )} +
+
+ +
+ + {log.macAddress} + + {(log.deviceNickname ?? log.deviceReportedHostname) && ( + + {log.deviceNickname ?? log.deviceReportedHostname} + + )} +
+
+ +
+ + {log.nasIpAddress ?? '-'} + + {log.nasMacAddress && ( + + {log.nasMacAddress} + + )} + {log.nasComment && ( + + {log.nasComment} + + )} +
+
+ + {log.result ? ( + + {t('wifiAdminDashboard.success')} + + ) : ( + + {log.failureReason ?? t('wifiAdminDashboard.failed')} + + )} + +
+ ))} +
+
+
+ +
+

+ {t('wifiAdminDashboard.showingPage', { page: page + 1 })} +

+
+ + +
+
+
+
+ ); +} diff --git a/apps/iris/src/routes/_private/admin/wifi/nas.tsx b/apps/iris/src/routes/_private/admin/wifi/nas.tsx new file mode 100644 index 00000000..5dbcc6c8 --- /dev/null +++ b/apps/iris/src/routes/_private/admin/wifi/nas.tsx @@ -0,0 +1,178 @@ +import { permissions } from '@filcdev/api/permissions'; +import { Button } from '@filcdev/ui/components/button'; +import { Input } from '@filcdev/ui/components/input'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@filcdev/ui/components/table'; +import { createFileRoute } from '@tanstack/react-router'; +import { Pencil, Plus, RefreshCw, Search, Trash } from 'lucide-react'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PermissionGuard } from '@/components/util/permission-guard'; +import { WifiNasDialog } from '@/components/wifi/wifi-dialogs'; +import { useDeleteWifiNas, useWifiNas, type WifiNas } from '@/hooks/wifi-admin'; +import { confirmDestructiveAction } from '@/utils/confirm'; + +export const Route = createFileRoute('/_private/admin/wifi/nas')({ + component: () => ( + + + + ), +}); + +function WifiNasPage() { + const { t } = useTranslation(); + const [search, setSearch] = useState(''); + const [dialogOpen, setDialogOpen] = useState(false); + const [editingNas, setEditingNas] = useState(); + + const query = useWifiNas(); + const deleteNas = useDeleteWifiNas(); + + const nasList = query.data?.nas ?? []; + const needle = search.toLowerCase(); + const filteredNas = nasList.filter( + (nas) => + !needle || + nas.ipAddress.includes(needle) || + nas.macAddress.toLowerCase().includes(needle) || + (nas.comment?.toLowerCase() ?? '').includes(needle) + ); + + return ( +
+
+
+

+ {t('wifiAdminNas.title')} +

+

+ {t('wifiAdminNas.description')} +

+
+
+ + +
+
+ +
+
+ + setSearch(event.target.value)} + placeholder={t('wifiAdminNas.searchPlaceholder')} + value={search} + /> +
+
+ +
+ + + + {t('wifiAdminNas.ipAddress')} + {t('wifiAdminNas.macAddress')} + {t('wifiAdminNas.comment')} + + {t('wifiAdminNas.actions')} + + + + + {query.isLoading && ( + + + {t('common.loading')} + + + )} + {!query.isLoading && filteredNas.length === 0 && ( + + + {t('wifiAdminNas.noNasFound')} + + + )} + {!query.isLoading && + filteredNas.map((nas) => ( + + + {nas.ipAddress} + + + {nas.macAddress} + + {nas.comment ?? '-'} + +
+ + +
+
+
+ ))} +
+
+
+ + +
+ ); +} diff --git a/apps/iris/src/routes/_private/admin/wifi/speed-profiles.tsx b/apps/iris/src/routes/_private/admin/wifi/speed-profiles.tsx new file mode 100644 index 00000000..1fbca60b --- /dev/null +++ b/apps/iris/src/routes/_private/admin/wifi/speed-profiles.tsx @@ -0,0 +1,366 @@ +import { permissions } from '@filcdev/api/permissions'; +import { Badge } from '@filcdev/ui/components/badge'; +import { Button } from '@filcdev/ui/components/button'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@filcdev/ui/components/table'; +import { createFileRoute } from '@tanstack/react-router'; +import { Pencil, Plus, RefreshCw, Trash } from 'lucide-react'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PermissionGuard } from '@/components/util/permission-guard'; +import { + WifiRoleProfileDialog, + WifiSpeedProfileDialog, +} from '@/components/wifi/wifi-dialogs'; +import { + useDeleteWifiRoleProfile, + useDeleteWifiSpeedProfile, + useWifiRoleProfiles, + useWifiSpeedProfiles, + type WifiRoleSpeedProfile, + type WifiSpeedProfile, +} from '@/hooks/wifi-admin'; +import { confirmDestructiveAction } from '@/utils/confirm'; + +/** + * The controller ships a built-in `Default` group; it is not something an admin + * edits, so it is hidden. Everything that keys off the visible list uses the + * filtered array — checking the raw list would render an empty table with no + * empty-state message. + */ +const HIDDEN_PROFILE_NAME = 'Default'; + +export const Route = createFileRoute('/_private/admin/wifi/speed-profiles')({ + component: () => ( + + + + ), +}); + +function WifiSpeedProfilesPage() { + const { t } = useTranslation(); + const [profileDialogOpen, setProfileDialogOpen] = useState(false); + const [mappingDialogOpen, setMappingDialogOpen] = useState(false); + + const [editingProfile, setEditingProfile] = useState< + WifiSpeedProfile | undefined + >(); + const [editingMapping, setEditingMapping] = useState< + WifiRoleSpeedProfile | undefined + >(); + + const profilesQuery = useWifiSpeedProfiles(); + const mappingsQuery = useWifiRoleProfiles(); + + const deleteProfile = useDeleteWifiSpeedProfile(); + const deleteMapping = useDeleteWifiRoleProfile(); + + const profiles = profilesQuery.data?.speedProfiles ?? []; + const mappings = mappingsQuery.data?.roleSpeedProfiles ?? []; + const visibleProfiles = profiles.filter( + (profile) => profile.name !== HIDDEN_PROFILE_NAME + ); + + return ( +
+
+
+

+ {t('wifiAdminProfiles.title')} +

+

+ {t('wifiAdminProfiles.description')} +

+
+
+ +
+
+ +
+
+

+ {t('wifiAdminProfiles.speedProfilesSection')} +

+ +
+ +
+ + + + {t('wifiAdminProfiles.name')} + {t('wifiAdminProfiles.download')} + {t('wifiAdminProfiles.upload')} + + {t('wifiAdminProfiles.actions')} + + + + + {profilesQuery.isLoading && ( + + + {t('common.loading')} + + + )} + {!profilesQuery.isLoading && visibleProfiles.length === 0 && ( + + + {t('wifiAdminProfiles.noProfilesFound')} + + + )} + {!profilesQuery.isLoading && + visibleProfiles.map((profile) => ( + + + {profile.name} + {profile.isWlanDefault && ( + + {t('wifi.wlan_default')} + + )} + + + {profile.downloadSpeedMbps === null ? ( + + {t('wifiAdminProfiles.unlimited')} + + ) : ( + profile.downloadSpeedMbps + )} + + + {profile.uploadSpeedMbps === null ? ( + + {t('wifiAdminProfiles.unlimited')} + + ) : ( + profile.uploadSpeedMbps + )} + + +
+ + +
+
+
+ ))} +
+
+
+
+ +
+
+

+ {t('wifiAdminProfiles.roleMappingsSection')} +

+ +
+ +
+ + + + {t('wifiAdminProfiles.priority')} + {t('wifiAdminProfiles.roleName')} + {t('wifiAdminProfiles.speedProfile')} + {t('wifiAdminProfiles.download')} + {t('wifiAdminProfiles.upload')} + + {t('wifiAdminProfiles.actions')} + + + + + {mappingsQuery.isLoading && ( + + + {t('common.loading')} + + + )} + {!mappingsQuery.isLoading && mappings.length === 0 && ( + + + {t('wifiAdminProfiles.noMappingsFound')} + + + )} + {!mappingsQuery.isLoading && + mappings.map((mapping) => { + const profile = profiles.find( + (entry) => entry.id === mapping.speedProfileId + ); + + return ( + + + {mapping.priority} + + + {mapping.roleName} + + + {profile ? ( + profile.name + ) : ( + + {mapping.speedProfileId} + + )} + + + {profile?.downloadSpeedMbps === null ? ( + + {t('wifiAdminProfiles.unlimited')} + + ) : ( + (profile?.downloadSpeedMbps ?? '-') + )} + + + {profile?.uploadSpeedMbps === null ? ( + + {t('wifiAdminProfiles.unlimited')} + + ) : ( + (profile?.uploadSpeedMbps ?? '-') + )} + + +
+ + +
+
+
+ ); + })} +
+
+
+
+ + + +
+ ); +} diff --git a/apps/iris/src/routes/_private/admin/wifi/users.tsx b/apps/iris/src/routes/_private/admin/wifi/users.tsx new file mode 100644 index 00000000..cd678fcc --- /dev/null +++ b/apps/iris/src/routes/_private/admin/wifi/users.tsx @@ -0,0 +1,636 @@ +import { permissions } from '@filcdev/api/permissions'; +import { Badge } from '@filcdev/ui/components/badge'; +import { Button } from '@filcdev/ui/components/button'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@filcdev/ui/components/collapsible'; +import { Input } from '@filcdev/ui/components/input'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@filcdev/ui/components/table'; +import { createFileRoute } from '@tanstack/react-router'; +import dayjs from 'dayjs'; +import { + AlertTriangle, + Pencil, + Plus, + RefreshCw, + Search, + ShieldAlert, + Trash, + Users, +} from 'lucide-react'; +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PermissionGuard } from '@/components/util/permission-guard'; +import { RelativeTime } from '@/components/util/relative-time'; +import { + WifiDeviceDialog, + WifiUserDialog, +} from '@/components/wifi/wifi-dialogs'; +import { + defaultWifiFilterState, + type WifiFilterState, + WifiFilters, +} from '@/components/wifi/wifi-filters'; +import { + useDeleteWifiDevice, + useDeleteWifiUser, + useWifiDevices, + useWifiSpeedProfiles, + useWifiUsers, + type WifiDevice, + type WifiUser, +} from '@/hooks/wifi-admin'; +import { confirmDestructiveAction } from '@/utils/confirm'; + +/** Devices idle longer than this are flagged. */ +const STALE_DAYS = 45; + +const PAGE_SIZE = 10_000; + +/** A user row plus the devices the table groups under it. */ +type WifiUserRow = WifiUser & { + devices: WifiDevice[]; + filteredDevices: WifiDevice[]; + isOrphan: boolean; +}; + +const formatMac = (mac: string): string => + mac + .replace(/[^0-9a-fA-F]/g, '') + .match(/.{1,2}/g) + ?.join(':') + .toUpperCase() ?? mac; + +const isStale = (value: Date | null): boolean => + value !== null && dayjs().diff(dayjs(value), 'day') > STALE_DAYS; + +/** Whether one device survives the active filters. */ +const deviceMatchesFilters = ( + device: WifiDevice, + userBanned: boolean, + filters: WifiFilterState, + macCounts: Map +): boolean => { + if (filters.bannedOnly && !(device.banned || userBanned)) { + return false; + } + if (filters.inactiveOnly && !isStale(device.lastActiveAt)) { + return false; + } + if ( + filters.activeOnly && + (device.lastActiveAt === null || isStale(device.lastActiveAt)) + ) { + return false; + } + if ( + filters.sharedMacsOnly && + (macCounts.get(device.macAddress.toLowerCase()) ?? 1) <= 1 + ) { + return false; + } + return true; +}; + +/** Whether one account survives the filters that are not device-scoped. */ +const userMatchesFilters = ( + user: WifiUserRow, + filters: WifiFilterState, + profileFilter: string | null +): boolean => { + if (filters.minDevices > 0 && user.devices.length < filters.minDevices) { + return false; + } + if ( + filters.speedProfileId !== null && + !user.isOrphan && + user.speedProfileId !== profileFilter + ) { + return false; + } + if (filters.manualOnly && !user.isOrphan && user.userId !== user.createdBy) { + return false; + } + return true; +}; + +export const Route = createFileRoute('/_private/admin/wifi/users')({ + component: () => ( + + + + ), +}); + +function WifiUsersPage() { + const { t } = useTranslation(); + const [search, setSearch] = useState(''); + const [filters, setFilters] = useState( + defaultWifiFilterState + ); + + const [userDialogOpen, setUserDialogOpen] = useState(false); + const [editingUser, setEditingUser] = useState(); + + const [deviceDialogOpen, setDeviceDialogOpen] = useState(false); + const [editingDevice, setEditingDevice] = useState(); + const [targetUserId, setTargetUserId] = useState(null); + const [expandedUsers, setExpandedUsers] = useState>( + {} + ); + + const usersQuery = useWifiUsers({ limit: PAGE_SIZE, offset: 0 }); + const devicesQuery = useWifiDevices({ limit: PAGE_SIZE, offset: 0 }); + const profilesQuery = useWifiSpeedProfiles(); + + const deleteUser = useDeleteWifiUser(); + const deleteDevice = useDeleteWifiDevice(); + + const users = usersQuery.data?.users ?? []; + const allDevices = devicesQuery.data?.devices ?? []; + const profiles = profilesQuery.data?.speedProfiles ?? []; + + const { combined, macCounts } = useMemo(() => { + const counts = new Map(); + for (const device of allDevices) { + const mac = device.macAddress.toLowerCase(); + counts.set(mac, (counts.get(mac) ?? 0) + 1); + } + + const devicesByUserId = new Map(); + for (const device of allDevices) { + const owner = device.wifiUserId; + const bucket = devicesByUserId.get(owner); + if (bucket) { + bucket.push(device); + } else { + devicesByUserId.set(owner, [device]); + } + } + + const rows: WifiUserRow[] = users.map((user) => ({ + ...user, + devices: devicesByUserId.get(user.id) ?? [], + filteredDevices: [], + isOrphan: false, + })); + + const orphans = devicesByUserId.get(null) ?? []; + if (orphans.length > 0) { + const lastActiveAt = orphans.reduce( + (latest, device) => + device.lastActiveAt && + (!latest || dayjs(device.lastActiveAt).isAfter(latest)) + ? device.lastActiveAt + : latest, + null + ); + rows.push({ + allowedMacAddresses: null, + banned: false, + comment: t('wifiAdminUsers.orphanDevicesDescription'), + createdAt: new Date(), + createdBy: null, + creatorName: null, + devices: orphans, + filteredDevices: [], + id: 'orphan', + isOrphan: true, + lastActiveAt, + speedProfileId: null, + updatedAt: new Date(), + userId: null, + username: t('wifiAdminUsers.orphanDevices'), + }); + } + + return { combined: rows, macCounts: counts }; + }, [allDevices, users, t]); + + const filteredData = useMemo(() => { + const needle = search.toLowerCase(); + const profileFilter = + filters.speedProfileId === 'none' ? null : filters.speedProfileId; + const deviceFiltersActive = + filters.inactiveOnly || + filters.activeOnly || + filters.sharedMacsOnly || + filters.bannedOnly; + + const matchesSearch = (user: WifiUserRow) => + !needle || + user.username.toLowerCase().includes(needle) || + (user.comment?.toLowerCase() ?? '').includes(needle); + + const deviceMatchesSearch = (device: WifiDevice) => + !needle || + device.macAddress.toLowerCase().includes(needle) || + (device.nickname?.toLowerCase() ?? '').includes(needle); + + return combined + .map((user) => { + const userMatchesSearch = matchesSearch(user); + const filteredDevices = user.devices.filter( + (device) => + (userMatchesSearch || deviceMatchesSearch(device)) && + deviceMatchesFilters(device, user.banned, filters, macCounts) + ); + + const keepUser = + (!needle || userMatchesSearch || filteredDevices.length > 0) && + (!deviceFiltersActive || filteredDevices.length > 0) && + userMatchesFilters(user, filters, profileFilter); + + return { ...user, filteredDevices, keepUser }; + }) + .filter( + (user) => + user.keepUser && + (user.filteredDevices.length > 0 || + !needle || + user.username.toLowerCase().includes(needle)) + ); + }, [combined, search, filters, macCounts]); + + const isLoading = usersQuery.isFetching || devicesQuery.isFetching; + + return ( +
+
+
+

+ {t('wifiAdminUsers.title')} +

+

+ {t('wifiAdminUsers.description')} +

+
+
+ + + +
+
+ +
+
+ + setSearch(event.target.value)} + placeholder={t('wifiAdminUsers.searchPlaceholder')} + value={search} + /> +
+ +
+ +
+ {filteredData.length === 0 ? ( +
+ {isLoading ? t('common.loading') : t('wifiAdminUsers.noUsersFound')} +
+ ) : ( +
+ {filteredData.map((user) => ( + + + setExpandedUsers((previous) => ({ + ...previous, + [user.id]: !previous[user.id], + })) + } + > +
+
+
+ + {user.username} + {user.banned && ( + + {t('wifiAdminUsers.banned')} + + )} + {isStale(user.lastActiveAt) && !user.isOrphan && ( + + {t('wifiAdminUsers.inactiveWarning')} + + )} + {!user.isOrphan && user.createdBy === null && ( + + {t('wifiAdminUsers.createdByDeletedAdmin')} + + )} + {!user.isOrphan && + user.createdBy !== null && + user.userId !== user.createdBy && ( + + {t('wifiAdminUsers.createdByHint', { + name: user.creatorName ?? 'Admin', + })} + + )} +
+
+ {user.comment && ( + + {user.comment} + + )} +
+
+ +
+ {!user.isOrphan && user.speedProfileId && ( +
+ + {profiles.find( + (profile) => profile.id === user.speedProfileId + )?.name ?? user.speedProfileId} + +
+ )} + {!user.isOrphan && ( +
+ {user.filteredDevices.length}{' '} + {t('wifiAdminUsers.devices')} +
+ )} + {!user.isOrphan && ( +
+ +
+ )} +
+
+
+ +
+ {!user.isOrphan && ( +
+
+ {user.speedProfileId && ( + + {t('wifiAdminUsers.speedProfile')}:{' '} + + {user.speedProfileId} + + + )} +
+
+ + + +
+
+ )} + + {user.filteredDevices.length > 0 ? ( +
+ + + + + {t('wifiAdminUsers.deviceMac')} + + + {t('wifiAdminUsers.deviceNickname')} + + + {t('wifiAdminUsers.lastActive')} + + + {t('wifiAdminUsers.status')} + + + {t('wifiAdminUsers.actions')} + + + + + {user.filteredDevices.map((device) => { + const macCount = + macCounts.get( + device.macAddress.toLowerCase() + ) ?? 1; + + return ( + + + {formatMac(device.macAddress)} + + +
+
+ + {device.nickname ?? '-'} + + {device.reportedHostname && ( + + ({device.reportedHostname}) + + )} +
+ {device.adminNotes && ( + + {device.adminNotes} + + )} +
+
+ + + + +
+ {device.banned && ( + + + {t('wifiAdminUsers.banned')} + + )} + {macCount > 1 && ( + + + {macCount}x + + )} +
+
+ +
+ + +
+
+
+ ); + })} +
+
+
+ ) : ( +
+ {t('wifiAdminUsers.noDevicesFound')} +
+ )} +
+
+
+ ))} +
+ )} +
+ + + +
+ ); +} diff --git a/apps/iris/src/routes/_private/route.tsx b/apps/iris/src/routes/_private/route.tsx index 34a68f8f..1cfdca0b 100644 --- a/apps/iris/src/routes/_private/route.tsx +++ b/apps/iris/src/routes/_private/route.tsx @@ -1,6 +1,6 @@ import { useSession } from '@filcdev/auth/client'; import { Spinner } from '@filcdev/ui/components/spinner'; -import { createFileRoute, Outlet } from '@tanstack/react-router'; +import { createFileRoute, Navigate, Outlet } from '@tanstack/react-router'; export const Route = createFileRoute('/_private')({ component: AppLayoutComponent, @@ -11,7 +11,7 @@ export const Route = createFileRoute('/_private')({ }); function AppLayoutComponent() { - const { isPending } = useSession(); + const { data, isPending } = useSession(); if (isPending) { return ( @@ -21,5 +21,13 @@ function AppLayoutComponent() { ); } + // Not every route under this layout has a permission to gate on — `/wifi` + // is a self-service page with no permission of its own — so the tree's + // authentication is enforced here. Without it those routes would sit on + // their loading skeleton forever for an anonymous visitor. + if (!(data?.session && data?.user)) { + return ; + } + return ; } diff --git a/apps/iris/src/routes/_private/wifi.tsx b/apps/iris/src/routes/_private/wifi.tsx new file mode 100644 index 00000000..748d8633 --- /dev/null +++ b/apps/iris/src/routes/_private/wifi.tsx @@ -0,0 +1,765 @@ +import { useSession } from '@filcdev/auth/client'; +import { + Alert, + AlertDescription, + AlertTitle, +} from '@filcdev/ui/components/alert'; +import { Badge } from '@filcdev/ui/components/badge'; +import { Button } from '@filcdev/ui/components/button'; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from '@filcdev/ui/components/card'; +import { Input } from '@filcdev/ui/components/input'; +import { PasswordInput } from '@filcdev/ui/components/password-input'; +import { Skeleton } from '@filcdev/ui/components/skeleton'; +import { Spinner } from '@filcdev/ui/components/spinner'; +import { isDefinedError } from '@orpc/client'; +import { useForm } from '@tanstack/react-form'; +import { createFileRoute, Link } from '@tanstack/react-router'; +import { AlertCircle, ArrowLeft, Download, Edit2, Wifi, X } from 'lucide-react'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Navbar } from '@/components/navbar'; +import { RelativeTime } from '@/components/util/relative-time'; +import { + useCreateWifiAccount, + useDownloadWifiCertificate, + useUpdateWifiDevice, + useUpdateWifiPassword, + useWifiSelf, + useWifiStatus, + type WifiSelfData, +} from '@/hooks/wifi'; +import { orpc, prefetch } from '@/utils/orpc'; + +const FALLBACK_SSID = 'Filc WiFi'; +const PASSWORD_MIN_LENGTH = 8; + +export const Route = createFileRoute('/_private/wifi')({ + component: WifiPage, + loader: ({ context }) => + prefetch(context.queryClient, orpc.wifi.status.queryOptions()), +}); + +/** Bans that apply to the whole account or to one of its devices. */ +function WifiAccountAlerts({ account }: { account: WifiSelfData }) { + const { t } = useTranslation(); + + if (account.banned) { + return ( + + + {t('wifi.accountBannedTitle')} + + {t('wifi.accountBannedDescription')} + + + ); + } + + if (!account.devices.some((device) => device.banned)) { + return null; + } + + return ( + + + {t('wifi.deviceBannedTitle')} + {t('wifi.deviceBannedDescription')} + + ); +} + +/** The state a render should show: the two queries are answered in order. */ +type WifiPageState = + | { kind: 'loading' } + | { kind: 'error' } + | { kind: 'hidden' } + | { account: WifiSelfData | null; hasOtherError: boolean; kind: 'content' }; + +const getWifiPageState = ({ + selfQuery, + statusQuery, +}: { + selfQuery: ReturnType; + statusQuery: ReturnType; +}): WifiPageState => { + if (statusQuery.isLoading) { + return { kind: 'loading' }; + } + if (statusQuery.isError) { + return { kind: 'error' }; + } + if (statusQuery.data?.enabled !== true) { + return { kind: 'hidden' }; + } + + // A 404 is the "no account yet" state, which renders the setup form. + const accountMissing = + selfQuery.isError && + isDefinedError(selfQuery.error) && + selfQuery.error.code === 'NOT_FOUND'; + + return { + account: accountMissing ? null : (selfQuery.data?.wifi ?? null), + hasOtherError: selfQuery.isError && !accountMissing, + kind: 'content', + }; +}; + +function WifiPage() { + const { t } = useTranslation(); + const { data: session } = useSession(); + + const statusQuery = useWifiStatus(); + const hasSession = Boolean(session?.session); + const selfQuery = useWifiSelf( + hasSession && statusQuery.data?.enabled === true + ); + + const ssid = statusQuery.data?.ssid || FALLBACK_SSID; + const state = getWifiPageState({ selfQuery, statusQuery }); + + if (state.kind === 'hidden') { + return null; + } + + if (state.kind === 'loading') { + return ( + <> + + + + ); + } + + if (state.kind === 'error') { + return ( + <> + +
+ + + {t('wifi.unavailable')} + {t('wifi.loadError')} + +
+ + ); + } + + const { account, hasOtherError } = state; + const showSetup = !(selfQuery.isLoading || account || hasOtherError); + + return ( + <> + +
+ + + {t('common.back')} + + +
+ +
+

+ {t('wifi.title')} +

+

{t('wifi.description')}

+
+
+ + {hasOtherError && ( + + + {t('wifi.unavailable')} + {t('wifi.loadError')} + + )} + + {selfQuery.isLoading && } + + {!selfQuery.isLoading && account && ( + <> + + + + + + + )} + + {showSetup && ( + + )} +
+ + ); +} + +function WifiLoadingState() { + return ( +
+
+ +
+ + + +
+ ); +} + +/** Setup form for a signed-in user who has no WiFi account yet. */ +function SetupAccount({ email, ssid }: { email: string; ssid: string }) { + const { t } = useTranslation(); + const createAccount = useCreateWifiAccount(); + + const form = useForm({ + defaultValues: { confirmPassword: '', password: '' }, + onSubmit: async ({ value }) => { + await createAccount.mutateAsync({ password: value.password }); + form.reset(); + }, + }); + + return ( + + + {t('wifi.createAccount')} + + +
+
+

+ {t('wifi.username')} +

+

{email}

+
+
+

+ {t('wifi.networkName')} +

+

{ssid}

+
+
+ + + + {t('wifi.passwordSecurityWarning')} + + {t('wifi.passwordSecurityWarningDescription')} + + + +
{ + event.preventDefault(); + form.handleSubmit(); + }} + > + { + if (!value) { + return t('wifi.passwordRequired'); + } + if (value.length < PASSWORD_MIN_LENGTH) { + return t('wifi.passwordTooShort'); + } + return undefined; + }, + }} + > + {(field) => ( +
+ + field.handleChange(event.target.value)} + placeholder={t('wifi.passwordPlaceholder')} + showPasswordLabel={t('common.showPassword')} + value={field.state.value} + /> + {field.state.meta.errors.length > 0 && ( +

+ {field.state.meta.errors[0]} +

+ )} +
+ )} +
+ + { + if (!value) { + return t('wifi.passwordRequired'); + } + if (value !== fieldApi.form.getFieldValue('password')) { + return t('wifi.passwordMismatch'); + } + return undefined; + }, + }} + > + {(field) => ( +
+ + field.handleChange(event.target.value)} + placeholder={t('wifi.confirmPasswordPlaceholder')} + showPasswordLabel={t('common.showPassword')} + value={field.state.value} + /> + {field.state.meta.errors.length > 0 && ( +

+ {field.state.meta.errors[0]} +

+ )} +
+ )} +
+ + +
+
+
+ ); +} + +function AccountSummary({ + account, + ssid, +}: { + account: WifiSelfData; + ssid: string; +}) { + const { t } = useTranslation(); + + return ( +
+ + +
+ {t('wifi.account')} + + {account.banned ? t('wifi.blocked') : t('wifi.active')} + +
+
+ +
+
+

+ {t('wifi.username')} +

+

{account.username}

+
+
+

+ {t('wifi.networkName')} +

+

{ssid}

+
+
+
+
+ + + + {t('wifi.speedLimit')} + + +
+

+ {t('wifi.download')} +

+

+ {account.speedLimit.downloadSpeedMbps ?? t('wifi.unlimited')} + {account.speedLimit.downloadSpeedMbps ? ' Mbps' : ''} +

+
+
+

{t('wifi.upload')}

+

+ {account.speedLimit.uploadSpeedMbps ?? t('wifi.unlimited')} + {account.speedLimit.uploadSpeedMbps ? ' Mbps' : ''} +

+
+
+

{t('wifi.source')}

+

+ {account.speedLimit.roleName + ? `${account.speedLimit.source} · ${account.speedLimit.roleName}` + : t(`wifi.${account.speedLimit.source}`)} +

+
+
+
+
+ ); +} + +function DeviceList({ account }: { account: WifiSelfData }) { + const { t } = useTranslation(); + const [editingId, setEditingId] = useState(null); + + return ( + + +
+ {t('wifi.devices')} + {account.devices.length} +
+
+ + {account.devices.length === 0 ? ( +

{t('wifi.noDevices')}

+ ) : ( + account.devices.map((device) => ( + setEditingId(null)} + onEditStart={() => setEditingId(device.id)} + /> + )) + )} +
+
+ ); +} + +function DeviceItem({ + device, + isEditing, + onEditStart, + onEditEnd, +}: { + device: WifiSelfData['devices'][number]; + isEditing: boolean; + onEditStart: () => void; + onEditEnd: () => void; +}) { + const { t } = useTranslation(); + const updateDevice = useUpdateWifiDevice(); + const [nickname, setNickname] = useState(device.nickname); + + const handleSave = async () => { + if (nickname !== device.nickname) { + await updateDevice.mutateAsync({ id: device.id, nickname }); + } + onEditEnd(); + }; + + return ( +
+ {isEditing ? ( +
+ setNickname(event.target.value || null)} + placeholder={device.reportedHostname || device.macAddress} + value={nickname ?? ''} + /> +
+ ) : ( +
+

+ {device.nickname || device.reportedHostname || device.macAddress} +

+
+ {device.reportedHostname && + device.nickname && + device.nickname !== device.reportedHostname && ( + <> + {device.reportedHostname} + • + + )} + + {device.macAddress} + +
+
+ )} +
+ {isEditing ? ( + <> + + + + ) : ( + <> +
+ + {device.banned ? t('wifi.blocked') : t('wifi.active')} + + + {device.lastActiveAt ? ( + + ) : ( + t('wifi.never') + )} + +
+ {!device.banned && ( + + )} + + )} +
+
+ ); +} + +function PasswordChangeForm() { + const { t } = useTranslation(); + const updatePassword = useUpdateWifiPassword(); + const [showForm, setShowForm] = useState(false); + + const form = useForm({ + defaultValues: { confirmPassword: '', newPassword: '' }, + onSubmit: async ({ value }) => { + await updatePassword.mutateAsync({ newPassword: value.newPassword }); + setShowForm(false); + form.reset(); + }, + }); + + return ( + + +
+ {t('wifi.changePassword')} + {!showForm && ( + + )} +
+
+ {showForm && ( + + + + {t('wifi.passwordChangeWarning')} + + {t('wifi.passwordChangeWarningDescription')} + + + + + {t('wifi.passwordSecurityWarning')} + + {t('wifi.passwordSecurityWarningDescription')} + + +
{ + event.preventDefault(); + form.handleSubmit(); + }} + > + { + if (!value) { + return t('wifi.passwordRequired'); + } + if (value.length < PASSWORD_MIN_LENGTH) { + return t('wifi.passwordTooShort'); + } + return undefined; + }, + }} + > + {(field) => ( +
+ + field.handleChange(event.target.value)} + placeholder={t('wifi.newPasswordPlaceholder')} + showPasswordLabel={t('common.showPassword')} + value={field.state.value} + /> + {field.state.meta.errors.length > 0 && ( +

+ {field.state.meta.errors[0]} +

+ )} +
+ )} +
+ + { + if (!value) { + return t('wifi.passwordRequired'); + } + if (value !== fieldApi.form.getFieldValue('newPassword')) { + return t('wifi.passwordMismatch'); + } + return undefined; + }, + }} + > + {(field) => ( +
+ + field.handleChange(event.target.value)} + placeholder={t('wifi.confirmPasswordPlaceholder')} + showPasswordLabel={t('common.showPassword')} + value={field.state.value} + /> + {field.state.meta.errors.length > 0 && ( +

+ {field.state.meta.errors[0]} +

+ )} +
+ )} +
+ +
+ + +
+
+
+ )} +
+ ); +} + +function CertificateDownload() { + const { t } = useTranslation(); + const downloadCertificate = useDownloadWifiCertificate(); + + return ( + + +
+ {t('wifi.certificate')} + +
+
+ +

+ {t('wifi.certificateDescription')} +

+
+
+ ); +} diff --git a/apps/iris/src/routes/auth/welcome.tsx b/apps/iris/src/routes/auth/welcome.tsx index 799f3274..f4465f32 100644 --- a/apps/iris/src/routes/auth/welcome.tsx +++ b/apps/iris/src/routes/auth/welcome.tsx @@ -27,7 +27,14 @@ import { Spinner } from '@filcdev/ui/components/spinner'; import Stepper, { Step } from '@filcdev/ui/components/stepper'; import { cn } from '@filcdev/ui/lib/utils'; import { createFileRoute, useNavigate } from '@tanstack/react-router'; -import { Check, ChevronDown, CircleCheck, Mail, User } from 'lucide-react'; +import { + Check, + ChevronDown, + CircleCheck, + Mail, + User, + Wifi, +} from 'lucide-react'; import { type ReactNode, useEffect, useId, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; @@ -36,6 +43,7 @@ import { ADMIN_UI_PERMISSIONS, useHasPermission, } from '@/hooks/use-has-permission'; +import { useWifiStatus } from '@/hooks/wifi'; export const Route = createFileRoute('/auth/welcome')({ component: RouteComponent, @@ -112,6 +120,9 @@ const WelcomeStepper = ({ user }: { user: UserType }) => { user.cohortId ?? null ); + const wifiStatus = useWifiStatus(); + const showWifiStep = wifiStatus.data?.enabled === true; + const isStaff = useHasPermission(ADMIN_UI_PERMISSIONS, user.permissions); const normalizedNickname = normalizeNickname(nicknameInput); @@ -196,9 +207,23 @@ const WelcomeStepper = ({ user }: { user: UserType }) => { if (currentStep === 3 && isStaff) { return t('common.skip'); } + if (currentStep === 4 && showWifiStep) { + return t('common.skip'); + } return t('common.next'); }; + // The step count must not change while the stepper is mounted: inserting the + // WiFi step after the status query resolves would shift the step the user is + // already on. Waiting for the answer keeps the list fixed for the whole run. + if (wifiStatus.isLoading) { + return ( +
+ +
+ ); + } + return (
{
+ {showWifiStep && ( + +
+

{t('wifi.setup')}

+

+ {t('welcome.wifiDescription')} +

+ + + +

{t('welcome.wifiPrompt')}

+ +
+
+
+
+ )} +
diff --git a/apps/iris/src/utils/form-schemas.ts b/apps/iris/src/utils/form-schemas.ts index 1cf00071..355a503f 100644 --- a/apps/iris/src/utils/form-schemas.ts +++ b/apps/iris/src/utils/form-schemas.ts @@ -103,3 +103,71 @@ export const timetableEditSchema = z.object({ validFrom: z.date().optional(), validTo: z.date().optional(), }); + +/** + * WiFi admin forms. These mirror the wire schemas in + * `@filcdev/api/domains/wifi/admin` without their transforms and defaults: + * TanStack Form's `validators` take a schema whose *input* type equals the + * form values, and a `z.default()`/`z.transform()` makes the input optional. + * The wire schema is still what the mutation sends — only validation runs here. + */ +// Mirrors the wire schema: the stored form is canonical (12 hex digits, no +// separators) and an operator may type either form. +const wifiMacSchema = z + .string() + .regex( + /^(?:[0-9a-fA-F]{12}|(?:[0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2})$/, + 'Invalid MAC address' + ); + +export const WIFI_PASSWORD_MIN_LENGTH = 8; + +/** + * The user dialog's validator. A single factory (rather than a create/update + * pair) because TanStack Form's `validators` option takes one schema per hook: + * a ternary between two schema types is not assignable. + */ +export const wifiUserFormValidator = (isEditing: boolean) => + z + .object({ + allowedMacAddresses: z.array(wifiMacSchema).optional(), + banned: z.boolean(), + comment: z.string(), + password: z.string().max(256), + speedProfileId: z.string().nullable(), + username: z.string().min(1), + }) + .refine( + (value) => + value.password === '' ? isEditing : value.password.length >= 8, + { + message: `Password must be at least ${WIFI_PASSWORD_MIN_LENGTH} characters`, + path: ['password'], + } + ); + +export const wifiDeviceFormSchema = z.object({ + adminNotes: z.string(), + banned: z.boolean(), + macAddress: wifiMacSchema, + nickname: z.string(), + wifiUserId: z.string().nullable(), +}); + +export const wifiNasFormSchema = z.object({ + comment: z.string(), + ipAddress: z.string().min(1), + macAddress: wifiMacSchema, +}); + +export const wifiSpeedProfileFormSchema = z.object({ + downloadSpeedMbps: z.number().int().min(-1), + name: z.string().min(1), + uploadSpeedMbps: z.number().int().min(-1), +}); + +export const wifiRoleProfileFormSchema = z.object({ + priority: z.number().int(), + roleName: z.string().min(1), + speedProfileId: z.string().min(1), +}); diff --git a/apps/radius/.env.example b/apps/radius/.env.example new file mode 100644 index 00000000..895fa5a1 --- /dev/null +++ b/apps/radius/.env.example @@ -0,0 +1,8 @@ +# Shared sercret for FreeRADIUS server, chronos will use this to authenticate with the server. Make sure to set the same value here and in chronos configuration. +FREERADIUS_SHARED_SECRET=FILL_ME_IN +# radtest user/password/secret for health check +RADTEST_USER=radtest +RADTEST_PASS=FILL_ME_IN +RADTEST_SECRET=FILL_ME_IN +# Timezone for logging +TZ=Europe/Budapest \ No newline at end of file diff --git a/apps/radius/certs/.gitignore b/apps/radius/certs/.gitignore new file mode 100644 index 00000000..5c86ccca --- /dev/null +++ b/apps/radius/certs/.gitignore @@ -0,0 +1,10 @@ +# Ignore everything +* + +# Except the CA certificates +!.gitignore +!ca.pem +!ca.der +!ca.crl +!passwords.example.mk +!Makefile \ No newline at end of file diff --git a/apps/radius/certs/Makefile b/apps/radius/certs/Makefile new file mode 100644 index 00000000..9d0ca324 --- /dev/null +++ b/apps/radius/certs/Makefile @@ -0,0 +1,186 @@ +###################################################################### +# +# Make file to be installed in /etc/raddb/certs to enable +# the easy creation of certificates. +# +# See the README file in this directory for more information. +# +# $Id: c9fbc9e8646bb157092e48c95352cb2866bafeaf $ +# +###################################################################### + +DH_KEY_SIZE = 2048 +OPENSSL = openssl +EXTERNAL_CA = $(wildcard external_ca.*) + +ifneq "$(EXTERNAL_CA)" "" +PARTIAL = -partial_chain +endif + +# +# Set the passwords +# +include passwords.mk + +###################################################################### +# +# Make the necessary files, but not client certificates. +# +###################################################################### +.PHONY: all +all: index.txt serial dh ca server client + +.PHONY: client +client: client.pem + +.PHONY: ca +ca: ca.der ca.crl + +.PHONY: server +server: server.pem server.vrfy + +.PHONY: inner-server +inner-server: inner-server.pem inner-server.vrfy + +.PHONY: verify +verify: server.vrfy client.vrfy + +passwords.mk: server.cnf ca.cnf client.cnf inner-server.cnf + @echo "PASSWORD_SERVER = '$(shell grep output_password server.cnf | sed 's/.*=//;s/^ *//')'" > $@ + @echo "PASSWORD_INNER = '$(shell grep output_password inner-server.cnf | sed 's/.*=//;s/^ *//')'" >> $@ + @echo "PASSWORD_CA = '$(shell grep output_password ca.cnf | sed 's/.*=//;s/^ *//')'" >> $@ + @echo "PASSWORD_CLIENT = '$(shell grep output_password client.cnf | sed 's/.*=//;s/^ *//')'" >> $@ + @echo "USER_NAME = '$(shell grep emailAddress client.cnf | grep '@' | sed 's/.*=//;s/^ *//')'" >> $@ + @echo "CA_DEFAULT_DAYS = '$(shell grep default_days ca.cnf | sed 's/.*=//;s/^ *//')'" >> $@ + +###################################################################### +# +# Diffie-Hellman parameters +# +###################################################################### +dh: + $(OPENSSL) dhparam -out dh -2 $(DH_KEY_SIZE) + +###################################################################### +# +# Create a new self-signed CA certificate +# +###################################################################### +ca.key ca.pem: ca.cnf + @[ -f index.txt ] || $(MAKE) index.txt + @[ -f serial ] || $(MAKE) serial + $(OPENSSL) req -new -x509 -keyout ca.key -out ca.pem \ + -days $(CA_DEFAULT_DAYS) -config ./ca.cnf \ + -passin pass:$(PASSWORD_CA) -passout pass:$(PASSWORD_CA) + chmod g+r ca.key + +ca.der: ca.pem + $(OPENSSL) x509 -inform PEM -outform DER -in ca.pem -out ca.der + +ca.crl: ca.pem + $(OPENSSL) ca -gencrl -keyfile ca.key -cert ca.pem -config ./ca.cnf -out ca-crl.pem -key $(PASSWORD_CA) + $(OPENSSL) crl -in ca-crl.pem -outform der -out ca.crl + rm ca-crl.pem + +###################################################################### +# +# Create a new server certificate, signed by the above CA. +# +###################################################################### +server.csr server.key: server.cnf + $(OPENSSL) req -new -out server.csr -keyout server.key -config ./server.cnf + chmod g+r server.key + +server.crt: ca.key ca.pem server.csr + $(OPENSSL) ca -batch -keyfile ca.key -cert ca.pem -in server.csr -key $(PASSWORD_CA) -out server.crt -extensions xpserver_ext -extfile xpextensions -config ./server.cnf + +server.p12: server.crt + $(OPENSSL) pkcs12 -export -in server.crt -inkey server.key -out server.p12 -passin pass:$(PASSWORD_SERVER) -passout pass:$(PASSWORD_SERVER) + chmod g+r server.p12 + +server.pem: server.p12 + $(OPENSSL) pkcs12 -in server.p12 -out server.pem -passin pass:$(PASSWORD_SERVER) -passout pass:$(PASSWORD_SERVER) + chmod g+r server.pem + +.PHONY: server.vrfy +server.vrfy: ca.pem + @$(OPENSSL) verify $(PARTIAL) -CAfile ca.pem server.pem + +###################################################################### +# +# Create a new client certificate, signed by the the above server +# certificate. +# +###################################################################### +client.csr client.key: client.cnf + $(OPENSSL) req -new -out client.csr -keyout client.key -config ./client.cnf + chmod g+r client.key + +client.crt: ca.key ca.pem client.csr + $(OPENSSL) ca -batch -keyfile ca.key -cert ca.pem -in client.csr -key $(PASSWORD_CA) -out client.crt -extensions xpclient_ext -extfile xpextensions -config ./client.cnf + +client.p12: client.crt + $(OPENSSL) pkcs12 -export -in client.crt -inkey client.key -out client.p12 -passin pass:$(PASSWORD_CLIENT) -passout pass:$(PASSWORD_CLIENT) + chmod g+r client.p12 + cp client.p12 $(USER_NAME).p12 + +client.pem: client.p12 + $(OPENSSL) pkcs12 -in client.p12 -out client.pem -passin pass:$(PASSWORD_CLIENT) -passout pass:$(PASSWORD_CLIENT) + chmod g+r client.pem + cp client.pem $(USER_NAME).pem + +.PHONY: client.vrfy +client.vrfy: ca.pem client.pem + c_rehash . + $(OPENSSL) verify -CApath . client.pem + +###################################################################### +# +# Create a new inner-server certificate, signed by the above CA. +# +###################################################################### +inner-server.csr inner-server.key: inner-server.cnf + $(OPENSSL) req -new -out inner-server.csr -keyout inner-server.key -config ./inner-server.cnf + chmod g+r inner-server.key + +inner-server.crt: ca.key ca.pem inner-server.csr + $(OPENSSL) ca -batch -keyfile ca.key -cert ca.pem -in inner-server.csr -key $(PASSWORD_CA) -out inner-server.crt -extensions xpserver_ext -extfile xpextensions -config ./inner-server.cnf + +inner-server.p12: inner-server.crt + $(OPENSSL) pkcs12 -export -in inner-server.crt -inkey inner-server.key -out inner-server.p12 -passin pass:$(PASSWORD_INNER) -passout pass:$(PASSWORD_INNER) + chmod g+r inner-server.p12 + +inner-server.pem: inner-server.p12 + $(OPENSSL) pkcs12 -in inner-server.p12 -out inner-server.pem -passin pass:$(PASSWORD_INNER) -passout pass:$(PASSWORD_INNER) + chmod g+r inner-server.pem + +.PHONY: inner-server.vrfy +inner-server.vrfy: ca.pem + @$(OPENSSL) verify $(PARTIAL) -CAfile ca.pem inner-server.pem + +###################################################################### +# +# Miscellaneous rules. +# +###################################################################### +index.txt: + @touch index.txt + +serial: + @echo '01' > serial + +print: + $(OPENSSL) x509 -text -in server.crt + +printca: + $(OPENSSL) x509 -text -in ca.pem + +clean: + @rm -f *~ *old client.csr client.key client.crt client.p12 client.pem + +# +# Make a target that people won't run too often. +# +destroycerts: + rm -f *~ dh *.csr *.crt *.p12 *.der *.pem *.key index.txt* \ + serial* *\.0 *\.1 ca-crl.pem ca.crl diff --git a/apps/radius/certs/ca.crl b/apps/radius/certs/ca.crl new file mode 100644 index 0000000000000000000000000000000000000000..a184e18359e02ac5eb7f2e7f4440fb9bf8015806 GIT binary patch literal 468 zcmXqLV!U9`xYK}_jZ>@5qwPB{BO^B}gF$1bA-4f18*?ZNn=q3{s3ETbH;BW*!xE5M zTw*9_zz^cG^KdworX&`Cgrp24KtfzRf=<4{QO*hhsU=03*$UyAZkdL%2GVfjIT^+H z$}`h49SXn-^fF4p(m<`uJR(r7t|395-U{KKZk`Ixj^eyVCWb~pY-(a=U={`Bni?1y z8AG{f4rgv+WMl}_;=5;*e)h+g@SR5t+tt+q_br%m&bG8mZc)zvZV!&*jg>@?yz-kJp)ol=X8jH)eOdwzA9`Q?2^hmKtTd_W+o z!8u;9DZuzqtqwC)1Mv*0|3KotRnyb literal 0 HcmV?d00001 diff --git a/apps/radius/certs/ca.der b/apps/radius/certs/ca.der new file mode 100644 index 0000000000000000000000000000000000000000..639a319348ceb95ada9f66f87f20385920eeb405 GIT binary patch literal 1251 zcmXqLV!3b7#C&`KGZP~dlZalM;;jvvj{keSVfFKi=a(3N3EZn}z{|#|)#lOmotKf3 zo0Y+!vD1*-fRl|ml!Z;0$s^Q|*MJ+u;oxBjNG&cg6g1!maoKq|oJvy?3qV3r1`;44 zE*?QA-{2@`g@DwOqRecC@JzQ%Ls3IHKQU{JZXiBSnTFd11Hn41{+ z84Q{jxtN+585!nZe)cV9_5SPb^>J@H9?9%(|6*aegIU+p^3*Gj8@W&Uf7BeCA-LXg z(Q7udm|t_2uf9<;yJC`MK%_@QRPlu!LTau0Vmx0sX+`{P`_pUDT z+0L@*hoA&&+TGxa#T*h|+qp6|3#T1_>vGg%`-4B8CLZ-HNlgZu_WW4sH;vNX>bW8Y;aW=00a#Z8Qszz{JtkOih0Sw0pq7LnA-f*l3F94?tm zs93SHhTC2F27|mo<4KUbGE3t@gT{RgSk)|S>|fH@GnkS<6ZRYi$}zISjEw(TSPhtg zlz}-&K%T|GK+iyDfz|?zHnohBl7eC@eSJvQ0Ob;3_CVq}JL)AD*Zf8jnf1S=3ar_{o`N|vBLcaFhI_g4K-&yC~HCx)vb^fom-K=dth0Id6{aMqT zss3|vgXHnM5pxT>t%W?>A1faCANiN9&#R?nVg|d|3?}bzy|z^WT{-%~lQ-unvESb_ zbyM>t_B?^Z3Ca^b@tAO}$PH*nY`PFBDst^riOu?-O!Gd5GRMe=&dhxMasKL)EIJn? zE&8_~TONPMvL*1!=C^ /opt/etc/raddb/mods-enabled/rest + +# Secure certs +chmod o-w /opt/etc/raddb/certs +chmod o-w /opt/etc/raddb/certs/*.pem || true +chmod o-w /opt/etc/raddb/certs/*.key || true +chmod o-w /opt/etc/raddb/certs/*.p12 || true +chmod o-w /opt/etc/raddb/certs/*.crt || true +chmod o-w /opt/etc/raddb/certs/*.csr || true +chmod o-w /opt/etc/raddb/certs/dh || true + +# Remove unused modules if exist +rm /opt/etc/raddb/mods-enabled/files || true + +# Configure eapol_test template +if [ -f /eapol_test.conf.template ]; then + sed "s/IDHERE/$RADTEST_USER/g" /eapol_test.conf.template | sed -e "s/PASSHERE/$RADTEST_PASS/g" > /eapol_test.conf +fi + + + +if [ "$#" -eq 0 ] || [ "${1#-}" != "$1" ]; then + set -- radiusd "$@" +fi + +if [ "$1" = 'radiusd' ] || [ "$1" = 'freeradius' ]; then + shift + exec radiusd -f "$@" +fi + +exec "$@" diff --git a/apps/radius/freeradius/eap b/apps/radius/freeradius/eap new file mode 100644 index 00000000..33614929 --- /dev/null +++ b/apps/radius/freeradius/eap @@ -0,0 +1,66 @@ +# Main configuration for outer EAP types +eap { + default_eap_type = peap + timer_expire = 60 + ignore_unknown_eap_types = no + cisco_accounting_username_bug = no + max_sessions = ${max_requests} + + md5 {} + pwd { + group = 19 + server_id = wifi@petrik.hu + virtual_server = "inner-tunnel" + } + gtc { + auth_type = PAP + } + + # Common TLS configuration + tls-config tls-common { + private_key_password = replace_with_password.mk_PASSWORD_SERVER + private_key_file = ${certdir}/server.key + certificate_file = ${certdir}/server.pem + ca_file = ${cadir}/ca.pem + ca_path = ${cadir} + dh_file = ${certdir}/dh + cipher_list = "DEFAULT" + cipher_server_preference = no + tls_min_version = "1.2" + tls_max_version = "1.2" + ecdh_curve = "" + cache { + enable = no + } + verify {} + ocsp { + enable = no + } + } + + tls { + tls = tls-common + } + ttls { + tls = tls-common + default_eap_type = mschapv2 + copy_request_to_tunnel = yes + use_tunneled_reply = no + virtual_server = "inner-tunnel" + } + peap { + tls = tls-common + default_eap_type = mschapv2 + copy_request_to_tunnel = yes + use_tunneled_reply = no + virtual_server = "inner-tunnel" + } + mschapv2 {} + fast { + tls = tls-common + cipher_list = "ALL:ADH:!EXPORT:!eNULL:!SSLv2" + authority_identity = "petrikwifi.hu" + pac_opaque_key = "FILL_ME_IN" + virtual_server = inner-tunnel + } +} diff --git a/apps/radius/freeradius/eapol_test.conf b/apps/radius/freeradius/eapol_test.conf new file mode 100644 index 00000000..f16e66b7 --- /dev/null +++ b/apps/radius/freeradius/eapol_test.conf @@ -0,0 +1,10 @@ +network={ + ssid="PetrikWifi" + key_mgmt=WPA-EAP + eap=PEAP + identity="IDHERE" + anonymous_identity="anonymous" + password="PASSHERE" + phase2="autheap=MSCHAPV2" + ca_cert="/opt/etc/raddb/certs/ca.pem" +} diff --git a/apps/radius/freeradius/inner-eap b/apps/radius/freeradius/inner-eap new file mode 100644 index 00000000..a66d935a --- /dev/null +++ b/apps/radius/freeradius/inner-eap @@ -0,0 +1,20 @@ +# Configuration for EAP types occurring inside the tunneled method (PEAP/TTLS) +eap inner-eap { + default_eap_type = mschapv2 + timer_expire = 60 + max_sessions = 2048 + + md5 {} + gtc { + auth_type = PAP + } + mschapv2 {} + + tls { + private_key_password = replace_with_password.mk_PASSWORD_INNER + private_key_file = ${certdir}/inner-server.key + certificate_file = ${certdir}/inner-server.pem + ca_file = ${cadir}/ca.pem + dh_file = ${certdir}/dh + } +} diff --git a/apps/radius/freeradius/inner-tunnel b/apps/radius/freeradius/inner-tunnel new file mode 100644 index 00000000..4bed97c9 --- /dev/null +++ b/apps/radius/freeradius/inner-tunnel @@ -0,0 +1,67 @@ +# Virtual server for inner-tunnel requests (EAP-TTLS and PEAP) +server inner-tunnel { + listen { + ipaddr = 127.0.0.1 + port = 18120 + type = auth + } + + authorize { + update control { + MS-CHAP-Use-NTLM-Auth := No + } + # Calls the REST module to query Chronos for authorization + rest + inner-eap + chap + pap + mschap + } + + authenticate { + inner-eap + Auth-Type CHAP { + chap + } + Auth-Type PAP { + pap + } + Auth-Type MS-CHAP { + mschap + } + } + + session { + radutmp + } + + post-auth { + update outer.session-state { + User-Name := &User-Name + } + -sql + if (1) { + update reply { + User-Name !* ANY + Message-Authenticator !* ANY + EAP-Message !* ANY + Proxy-State !* ANY + MS-MPPE-Encryption-Types !* ANY + MS-MPPE-Encryption-Policy !* ANY + MS-MPPE-Send-Key !* ANY + MS-MPPE-Recv-Key !* ANY + } + update outer.session-state { + Session-Timeout := &reply:Session-Timeout + } + } + + Post-Auth-Type REJECT { + -sql + attr_filter.access_reject + update outer.session-state { + &Module-Failure-Message := &request:Module-Failure-Message + } + } + } +} diff --git a/apps/radius/freeradius/rest b/apps/radius/freeradius/rest new file mode 100644 index 00000000..f8a05cea --- /dev/null +++ b/apps/radius/freeradius/rest @@ -0,0 +1,59 @@ +# Configuration for the REST module +# Connects FreeRADIUS to the Chronos backend API +rest { + tls { + check_cert_cn = no + } + + # Points to the Chronos backend API + connect_uri = "http://chronos:3000/api/wifi" + + authorize { + uri = "${..connect_uri}/radius/authorize" + method = 'post' + body = 'JSON' + data = '{"username": "%{User-Name}", "password": "%{User-Password}", "source": "%{Calling-Station-Id}", "destination": "%{Called-Station-Id}", "NAS": "%{NAS-Identifier}", "IP": "%{NAS-IP-Address}", "sharedSecret": "SECRET_HERE"}' + tls = ${..tls} + } + authenticate { + uri = "${..connect_uri}/radius/authenticate" + method = 'post' + body = 'JSON' + data = '{"username": "%{User-Name}", "password": "%{User-Password}", "source": "%{Calling-Station-Id}", "destination": "%{Called-Station-Id}", "NAS": "%{NAS-Identifier}", "IP": "%{NAS-IP-Address}", "sharedSecret": "SECRET_HERE"}' + tls = ${..tls} + } + preacct { + uri = "${..connect_uri}/radius/preacct" + method = 'post' + body = 'JSON' + tls = ${..tls} + } + accounting { + uri = "${..connect_uri}/radius/accounting" + method = 'post' + body = 'JSON' + tls = ${..tls} + } + post-auth { + uri = "${..connect_uri}/radius/post-auth" + method = 'post' + body = 'JSON' + tls = ${..tls} + } + + xlat { + body_uri_encode = yes + tls = ${..tls} + } + + pool { + start = ${thread[pool].start_servers} + min = ${thread[pool].min_spare_servers} + max = ${thread[pool].max_servers} + spare = ${thread[pool].max_spare_servers} + uses = 0 + retry_delay = 30 + lifetime = 0 + idle_timeout = 60 + } +} diff --git a/docs/freeradius-setup.md b/docs/freeradius-setup.md new file mode 100644 index 00000000..3f3e56c4 --- /dev/null +++ b/docs/freeradius-setup.md @@ -0,0 +1,32 @@ +# FreeRADIUS Architecture & Setup + +The FreeRADIUS deployment has been migrated to `apps/radius/` to sit alongside the `chronos` backend and `iris` frontend. The configuration files have been aggressively stripped of unnecessary comments, leaving a concise, production-ready setup compatible with MSCHAPv2, TTLS, and PEAP. + +## Directory Structure + +```text +apps/radius/ +├── docker-compose.yml # Defines the FreeRADIUS service +├── certs/ # Contains certificates +└── freeradius/ + ├── clients.conf # Defines who can connect (NAS setup) + ├── default # Outer EAP virtual server routing + ├── inner-tunnel # Inner MSCHAPv2/PEAP virtual server + ├── eap & inner-eap # TLS and inner EAP mechanisms + ├── rest # Proxies requests to Chronos backend + └── docker-entrypoint.sh # Permissions and symlink bootstrap +``` + +## How it Works + +1. **Authentication Flow**: Devices connect via WiFi using PEAP/MSCHAPv2. The outer EAP request hits the `default` virtual server. Once TLS negotiation is completed, the inner request is routed to `inner-tunnel`. +2. **REST API (Chronos)**: Instead of connecting directly to the database, FreeRADIUS is configured via the `rest` module to proxy authentication requests to the `chronos` backend over HTTP. Chronos handles password validation, permissions, and accounting. +3. **Docker Network**: The `docker-compose.yml` runs the Alpine-based FreeRADIUS image. In Coolify, you deploy this Docker Compose file and ensure it joins the same Docker network as the `chronos` backend (setting the `connect_uri` in the `rest` file to `http://chronos:3000`). + +## Deployment (Coolify & Docker Compose) + +When setting up via Coolify or standard Docker Compose: +- Deploy using the provided `docker-compose.yml` under `apps/radius`. +- The system expects the backend to be resolvable at `chronos:3000`. If your backend internal DNS is named differently, update `connect_uri` in `apps/radius/freeradius/rest`. +- FreeRADIUS exposes UDP ports `1812` (auth) and `1813` (acct). You must map these externally on your server so that your Access Points can reach them. + diff --git a/packages/api/src/contract/index.ts b/packages/api/src/contract/index.ts index a640b766..7db65045 100644 --- a/packages/api/src/contract/index.ts +++ b/packages/api/src/contract/index.ts @@ -14,6 +14,7 @@ import { pingContract } from './ping'; import { rolesContract } from './roles'; import { timetableContract } from './timetable'; import { usersContract } from './users'; +import { wifiContract } from './wifi'; /** * The whole Chronos API: one procedure per endpoint, implemented by @@ -39,6 +40,7 @@ export const appContract = oc.errors(apiErrors).router({ roles: rolesContract, timetable: timetableContract, users: usersContract, + wifi: wifiContract, }); export type AppContract = typeof appContract; diff --git a/packages/api/src/contract/wifi.ts b/packages/api/src/contract/wifi.ts new file mode 100644 index 00000000..2611bca7 --- /dev/null +++ b/packages/api/src/contract/wifi.ts @@ -0,0 +1,525 @@ +import { oc } from '@orpc/contract'; +import z from 'zod'; +import { + wifiAuthLogListQuerySchema, + wifiAuthLogSchema, + wifiDeviceCreateSchema, + wifiDeviceListQuerySchema, + wifiDeviceSchema, + wifiDeviceUpdateSchema, + wifiIdParamSchema, + wifiListQuerySchema, + wifiNasCreateSchema, + wifiNasSchema, + wifiNasUpdateSchema, + wifiRoleSpeedProfileCreateSchema, + wifiRoleSpeedProfileSchema, + wifiRoleSpeedProfileUpdateSchema, + wifiSpeedProfileCreateSchema, + wifiSpeedProfileSchema, + wifiSpeedProfileUpdateSchema, + wifiStringIdParamSchema, + wifiUserCreateSchema, + wifiUserSchema, + wifiUserUpdateSchema, +} from '../domains/wifi/admin'; +import { + radiusAuthorizeRequestSchema, + radiusAuthorizeResponseSchema, +} from '../domains/wifi/radius'; +import { + wifiSelfCreateResponseSchema, + wifiSelfCreateSchema, + wifiSelfDeviceSchema, + wifiSelfDeviceUpdateSchema, + wifiSelfPasswordChangeResponseSchema, + wifiSelfPasswordChangeSchema, +} from '../domains/wifi/self'; +import { wifiStatsOverviewSchema } from '../domains/wifi/stats'; +import { wifiStatusResponseSchema } from '../domains/wifi/status'; +import { filcRoute } from './route'; + +const TAGS = ['WiFi']; +const GROUP = 'WiFi'; + +export const wifiContract = { + authLogs: { + list: oc + .route( + filcRoute({ + auth: true, + description: 'List WiFi auth logs.', + group: GROUP, + method: 'GET', + operationId: 'getWifiAuthLogs', + path: '/wifi/auth-logs', + successStatus: 200, + tags: TAGS, + type: '@unit WifiAuthLogListResponse', + }) + ) + .input(wifiAuthLogListQuerySchema) + .output(z.object({ logs: z.array(wifiAuthLogSchema) })), + }, + devices: { + create: oc + .route( + filcRoute({ + auth: true, + description: 'Create a WiFi device or global MAC ban.', + group: GROUP, + method: 'POST', + operationId: 'postWifiDevices', + path: '/wifi/devices', + successStatus: 201, + tags: TAGS, + type: '@unit WifiDeviceResponse', + }) + ) + .input(wifiDeviceCreateSchema) + .output(z.object({ device: wifiDeviceSchema })), + delete: oc + .route( + filcRoute({ + auth: true, + description: 'Delete a WiFi device or global MAC ban.', + group: GROUP, + method: 'DELETE', + operationId: 'deleteWifiDevicesById', + path: '/wifi/devices/{id}', + successStatus: 200, + tags: TAGS, + type: '@nodata', + }) + ) + .input(wifiIdParamSchema) + .output(z.object({ id: z.uuid() })), + list: oc + .route( + filcRoute({ + auth: true, + description: 'List WiFi devices and global MAC bans.', + group: GROUP, + method: 'GET', + operationId: 'getWifiDevices', + path: '/wifi/devices', + successStatus: 200, + tags: TAGS, + type: '@unit WifiDeviceListResponse', + }) + ) + .input(wifiDeviceListQuerySchema) + .output(z.object({ devices: z.array(wifiDeviceSchema) })), + update: oc + .route( + filcRoute({ + auth: true, + description: 'Update a WiFi device or global MAC ban.', + group: GROUP, + method: 'PUT', + operationId: 'putWifiDevicesById', + path: '/wifi/devices/{id}', + successStatus: 200, + tags: TAGS, + type: '@unit WifiDeviceResponse', + }) + ) + .input(wifiDeviceUpdateSchema.extend(wifiIdParamSchema.shape)) + .output(z.object({ device: wifiDeviceSchema })), + }, + nas: { + create: oc + .route( + filcRoute({ + auth: true, + description: 'Register a WiFi NAS device.', + group: GROUP, + method: 'POST', + operationId: 'postWifiNas', + path: '/wifi/nas', + successStatus: 201, + tags: TAGS, + type: '@unit WifiNasResponse', + }) + ) + .input(wifiNasCreateSchema) + .output(z.object({ nas: wifiNasSchema })), + delete: oc + .route( + filcRoute({ + auth: true, + description: 'Delete a WiFi NAS device.', + group: GROUP, + method: 'DELETE', + operationId: 'deleteWifiNasById', + path: '/wifi/nas/{id}', + successStatus: 200, + tags: TAGS, + type: '@nodata', + }) + ) + .input(wifiIdParamSchema) + .output(z.object({ id: z.uuid() })), + list: oc + .route( + filcRoute({ + auth: true, + description: 'List authorized WiFi NAS devices.', + group: GROUP, + method: 'GET', + operationId: 'getWifiNas', + path: '/wifi/nas', + successStatus: 200, + tags: TAGS, + type: '@unit WifiNasListResponse', + }) + ) + .output(z.object({ nas: z.array(wifiNasSchema) })), + update: oc + .route( + filcRoute({ + auth: true, + description: 'Update a WiFi NAS device.', + group: GROUP, + method: 'PUT', + operationId: 'putWifiNasById', + path: '/wifi/nas/{id}', + successStatus: 200, + tags: TAGS, + type: '@unit WifiNasResponse', + }) + ) + .input(wifiNasUpdateSchema.extend(wifiIdParamSchema.shape)) + .output(z.object({ nas: wifiNasSchema })), + }, + radius: { + authorize: oc + .route( + filcRoute({ + description: 'Authorize a WiFi client for FreeRADIUS.', + group: GROUP, + method: 'POST', + operationId: 'postWifiRadiusAuthorize', + path: '/wifi/radius/authorize', + successStatus: 200, + tags: TAGS, + type: '@unit WifiRadiusAuthorizeResponse', + }) + ) + .input(radiusAuthorizeRequestSchema) + .output(radiusAuthorizeResponseSchema), + }, + roleSpeedProfiles: { + create: oc + .route( + filcRoute({ + auth: true, + description: 'Create a WiFi role speed-profile mapping.', + group: GROUP, + method: 'POST', + operationId: 'postWifiRoleSpeedProfiles', + path: '/wifi/role-speed-profiles', + successStatus: 201, + tags: TAGS, + type: '@unit WifiRoleSpeedProfileResponse', + }) + ) + .input(wifiRoleSpeedProfileCreateSchema) + .output(z.object({ mapping: wifiRoleSpeedProfileSchema })), + delete: oc + .route( + filcRoute({ + auth: true, + description: 'Delete a WiFi role speed-profile mapping.', + group: GROUP, + method: 'DELETE', + operationId: 'deleteWifiRoleSpeedProfilesById', + path: '/wifi/role-speed-profiles/{id}', + successStatus: 200, + tags: TAGS, + type: '@nodata', + }) + ) + .input(wifiStringIdParamSchema) + .output(z.object({ id: z.string() })), + list: oc + .route( + filcRoute({ + auth: true, + description: 'List WiFi role speed-profile mappings.', + group: GROUP, + method: 'GET', + operationId: 'getWifiRoleSpeedProfiles', + path: '/wifi/role-speed-profiles', + successStatus: 200, + tags: TAGS, + type: '@unit WifiRoleSpeedProfileListResponse', + }) + ) + .output( + z.object({ roleSpeedProfiles: z.array(wifiRoleSpeedProfileSchema) }) + ), + update: oc + .route( + filcRoute({ + auth: true, + description: 'Update a WiFi role speed-profile mapping.', + group: GROUP, + method: 'PUT', + operationId: 'putWifiRoleSpeedProfilesById', + path: '/wifi/role-speed-profiles/{id}', + successStatus: 200, + tags: TAGS, + type: '@unit WifiRoleSpeedProfileResponse', + }) + ) + .input( + wifiRoleSpeedProfileUpdateSchema.extend(wifiStringIdParamSchema.shape) + ) + .output(z.object({ mapping: wifiRoleSpeedProfileSchema })), + }, + self: { + certificate: oc + .route( + filcRoute({ + auth: true, + description: + "Download the CA certificate for the authenticated user's WiFi network.", + group: GROUP, + method: 'GET', + operationId: 'getWifiSelfCertificate', + path: '/wifi/self/certificate', + successStatus: 200, + tags: TAGS, + type: '@nodata', + }) + ) + .output(z.file()), + changePassword: oc + .route( + filcRoute({ + auth: true, + description: "Change the authenticated user's WiFi account password.", + group: GROUP, + method: 'PUT', + operationId: 'putWifiSelfPassword', + path: '/wifi/self/password', + successStatus: 200, + tags: TAGS, + type: '@nodata', + }) + ) + .input(wifiSelfPasswordChangeSchema) + .output(wifiSelfPasswordChangeResponseSchema), + create: oc + .route( + filcRoute({ + auth: true, + description: 'Create a new WiFi account for the authenticated user.', + group: GROUP, + method: 'POST', + operationId: 'postWifiSelf', + path: '/wifi/self', + successStatus: 201, + tags: TAGS, + type: '@unit WifiSelfCreateResponse @field(.wifi, WifiSelf)', + }) + ) + .input(wifiSelfCreateSchema) + .output(wifiSelfCreateResponseSchema), + get: oc + .route( + filcRoute({ + auth: true, + description: "Get the authenticated user's WiFi account and devices.", + group: GROUP, + method: 'GET', + operationId: 'getWifiSelf', + path: '/wifi/self', + successStatus: 200, + tags: TAGS, + type: '@unit WifiSelfResponse @field(.wifi, WifiSelf)', + }) + ) + .output(wifiSelfCreateResponseSchema), + updateDevice: oc + .route( + filcRoute({ + auth: true, + description: "Rename one of the authenticated user's WiFi devices.", + group: GROUP, + method: 'PUT', + operationId: 'putWifiSelfDevicesById', + path: '/wifi/self/devices/{id}', + successStatus: 200, + tags: TAGS, + type: '@unit WifiSelfDeviceResponse @field(.device, WifiSelfDevice)', + }) + ) + .input(wifiSelfDeviceUpdateSchema.extend(wifiIdParamSchema.shape)) + .output(z.object({ device: wifiSelfDeviceSchema })), + }, + speedProfiles: { + create: oc + .route( + filcRoute({ + auth: true, + description: 'Create a UniFi WiFi speed profile.', + group: GROUP, + method: 'POST', + operationId: 'postWifiSpeedProfiles', + path: '/wifi/speed-profiles', + successStatus: 201, + tags: TAGS, + type: '@unit WifiSpeedProfileResponse', + }) + ) + .input(wifiSpeedProfileCreateSchema) + .output(z.object({ profile: wifiSpeedProfileSchema })), + delete: oc + .route( + filcRoute({ + auth: true, + description: 'Delete a UniFi WiFi speed profile.', + group: GROUP, + method: 'DELETE', + operationId: 'deleteWifiSpeedProfilesById', + path: '/wifi/speed-profiles/{id}', + successStatus: 200, + tags: TAGS, + type: '@nodata', + }) + ) + .input(wifiStringIdParamSchema) + .output(z.object({ id: z.string() })), + list: oc + .route( + filcRoute({ + auth: true, + description: 'List UniFi WiFi speed profiles.', + group: GROUP, + method: 'GET', + operationId: 'getWifiSpeedProfiles', + path: '/wifi/speed-profiles', + successStatus: 200, + tags: TAGS, + type: '@unit WifiSpeedProfileListResponse', + }) + ) + .output(z.object({ speedProfiles: z.array(wifiSpeedProfileSchema) })), + update: oc + .route( + filcRoute({ + auth: true, + description: 'Update a UniFi WiFi speed profile.', + group: GROUP, + method: 'PUT', + operationId: 'putWifiSpeedProfilesById', + path: '/wifi/speed-profiles/{id}', + successStatus: 200, + tags: TAGS, + type: '@unit WifiSpeedProfileResponse', + }) + ) + .input(wifiSpeedProfileUpdateSchema.extend(wifiStringIdParamSchema.shape)) + .output(z.object({ profile: wifiSpeedProfileSchema })), + }, + stats: { + overview: oc + .route( + filcRoute({ + auth: true, + description: + 'Get WiFi account, device, active-device, and auth statistics.', + group: GROUP, + method: 'GET', + operationId: 'getWifiStatsOverview', + path: '/wifi/stats/overview', + successStatus: 200, + tags: TAGS, + type: '@unit WifiStatsResponse @field(.stats, WifiStats)', + }) + ) + .output(wifiStatsOverviewSchema), + }, + status: oc + .route( + filcRoute({ + description: 'Get the WiFi module status and configured SSID.', + group: GROUP, + method: 'GET', + operationId: 'getWifiStatus', + path: '/wifi/status', + successStatus: 200, + tags: TAGS, + type: '@unit WifiStatusResponse', + }) + ) + .output(wifiStatusResponseSchema), + users: { + create: oc + .route( + filcRoute({ + auth: true, + description: 'Create a WiFi user account.', + group: GROUP, + method: 'POST', + operationId: 'postWifiUsers', + path: '/wifi/users', + successStatus: 201, + tags: TAGS, + type: '@unit WifiUserResponse', + }) + ) + .input(wifiUserCreateSchema) + .output(z.object({ user: wifiUserSchema })), + delete: oc + .route( + filcRoute({ + auth: true, + description: 'Delete a WiFi user account.', + group: GROUP, + method: 'DELETE', + operationId: 'deleteWifiUsersById', + path: '/wifi/users/{id}', + successStatus: 200, + tags: TAGS, + type: '@nodata', + }) + ) + .input(wifiIdParamSchema) + .output(z.object({ id: z.uuid() })), + list: oc + .route( + filcRoute({ + auth: true, + description: 'List WiFi user accounts.', + group: GROUP, + method: 'GET', + operationId: 'getWifiUsers', + path: '/wifi/users', + successStatus: 200, + tags: TAGS, + type: '@unit WifiUserListResponse', + }) + ) + .input(wifiListQuerySchema) + .output(z.object({ users: z.array(wifiUserSchema) })), + update: oc + .route( + filcRoute({ + auth: true, + description: 'Update a WiFi user account.', + group: GROUP, + method: 'PUT', + operationId: 'putWifiUsersById', + path: '/wifi/users/{id}', + successStatus: 200, + tags: TAGS, + type: '@unit WifiUserResponse', + }) + ) + .input(wifiUserUpdateSchema.extend(wifiIdParamSchema.shape)) + .output(z.object({ user: wifiUserSchema })), + }, +}; diff --git a/packages/api/src/domains/wifi/admin.ts b/packages/api/src/domains/wifi/admin.ts new file mode 100644 index 00000000..3fbcba74 --- /dev/null +++ b/packages/api/src/domains/wifi/admin.ts @@ -0,0 +1,187 @@ +import z from 'zod'; + +// Stored MACs are canonical (12 hex digits, no separators — see the module's +// `canonicalizeMac`), while a caller may send either form. Both must validate: +// the same schema types the create/update input and the list output. +const macRegex = + /^(?:[0-9a-fA-F]{12}|(?:[0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2})$/; +const macAddressSchema = z.string().regex(macRegex, 'Invalid MAC address'); + +export const wifiIdParamSchema = z.object({ id: z.uuid() }); +export const wifiStringIdParamSchema = z.object({ id: z.string().min(1) }); +export const wifiListQuerySchema = z.object({ + limit: z.coerce.number().int().min(1).max(10_000).default(10_000), + offset: z.coerce.number().int().min(0).default(0), + search: z.string().optional(), +}); + +export const wifiDeviceListQuerySchema = wifiListQuerySchema.extend({ + // The typed client passes `uuid | null | undefined`; an OpenAPI caller can + // only send a string. The empty string and `'null'` both mean "no filter", + // and `.optional()` comes last so an absent field stays distinguishable from + // an explicit `null` (which means "unowned devices"). + wifiUserId: z + .union([z.uuid(), z.literal(''), z.literal('null'), z.null()]) + .transform((v) => (v == null || v === '' || v === 'null' ? null : v)) + .optional(), +}); + +export const wifiUserSchema = z.object({ + allowedMacAddresses: z.array(macAddressSchema).nullable(), + banned: z.boolean(), + comment: z.string().nullable(), + createdAt: z.date(), + createdBy: z.uuid().nullable(), + creatorName: z.string().nullable(), + id: z.uuid(), + lastActiveAt: z.date().nullable(), + speedProfileId: z.string().nullable(), + updatedAt: z.date(), + userId: z.uuid().nullable(), + username: z.string(), +}); + +export const wifiDeviceSchema = z.object({ + adminNotes: z.string().nullable(), + banned: z.boolean(), + createdAt: z.date(), + id: z.uuid(), + lastActiveAt: z.date().nullable(), + macAddress: macAddressSchema, + nickname: z.string().nullable(), + reportedHostname: z.string().nullable(), + updatedAt: z.date(), + wifiUserId: z.uuid().nullable(), +}); + +export const wifiNasSchema = z.object({ + comment: z.string().nullable(), + createdAt: z.date(), + id: z.uuid(), + ipAddress: z.string(), + macAddress: macAddressSchema, + updatedAt: z.date(), +}); + +export const wifiSpeedProfileSchema = z.object({ + downloadSpeedMbps: z.number().int().nullable(), + id: z.string(), + isWlanDefault: z.boolean(), + name: z.string(), + uploadSpeedMbps: z.number().int().nullable(), +}); + +/** + * A role mapping stores the reference only: the limits live on the referenced + * `wifi_speed_profile`, which the UI reads through `speedProfileId`. + */ +export const wifiRoleSpeedProfileSchema = z.object({ + priority: z.number().int(), + roleName: z.string(), + speedProfileId: z.string(), +}); + +export const wifiUserCreateSchema = z.object({ + allowedMacAddresses: z.array(macAddressSchema).optional(), + comment: z + .string() + .nullable() + .optional() + .transform((v) => (v === '' ? null : v)), + password: z.string().min(8).max(256), + speedProfileId: z.string().nullable().optional(), + userId: z.uuid().nullable().optional(), + username: z.string().min(1), +}); + +export const wifiUserUpdateSchema = wifiUserCreateSchema.partial().extend({ + banned: z.boolean().optional(), + password: z + .union([z.string().min(8).max(256), z.literal('')]) + .optional() + .transform((v) => (v === '' ? undefined : v)), +}); + +export const wifiDeviceCreateSchema = z.object({ + adminNotes: z + .string() + .nullable() + .optional() + .transform((v) => (v === '' ? null : v)), + banned: z.boolean().optional(), + macAddress: macAddressSchema, + nickname: z + .string() + .nullable() + .optional() + .transform((v) => (v === '' ? null : v)), + wifiUserId: z.uuid().nullable().optional(), +}); + +export const wifiDeviceUpdateSchema = wifiDeviceCreateSchema.partial(); + +export const wifiNasCreateSchema = z.object({ + comment: z + .string() + .nullable() + .optional() + .transform((v) => (v === '' ? null : v)), + ipAddress: z.string(), + macAddress: macAddressSchema, +}); + +export const wifiNasUpdateSchema = wifiNasCreateSchema.partial(); + +export const wifiSpeedProfileCreateSchema = z.object({ + downloadSpeedMbps: z.number().int().min(-1).default(-1), + name: z.string().min(1), + uploadSpeedMbps: z.number().int().min(-1).default(-1), +}); + +export const wifiSpeedProfileUpdateSchema = + wifiSpeedProfileCreateSchema.partial(); + +export const wifiRoleSpeedProfileCreateSchema = z.object({ + priority: z.number().int().default(0), + roleName: z.string().min(1), + speedProfileId: z.string().min(1), +}); + +export const wifiRoleSpeedProfileUpdateSchema = + wifiRoleSpeedProfileCreateSchema.partial(); + +export type WifiListQuery = z.infer; +export type WifiDeviceListQuery = z.infer; + +export type WifiUser = z.infer; +export type WifiDevice = z.infer; +export type WifiNas = z.infer; +export type WifiSpeedProfile = z.infer; +export type WifiRoleSpeedProfile = z.infer; + +export const wifiAuthLogSchema = z.object({ + deviceNickname: z.string().nullable(), + deviceReportedHostname: z.string().nullable(), + failureReason: z.string().nullable(), + id: z.number(), + macAddress: z.string(), + nasComment: z.string().nullable(), + nasIpAddress: z.string().nullable(), + nasMacAddress: z.string().nullable(), + result: z.boolean(), + timestamp: z.date(), + userComment: z.string().nullable(), + username: z.string(), + wifiUserId: z.uuid().nullable(), +}); + +export const wifiAuthLogListQuerySchema = wifiListQuerySchema.extend({ + failureReason: z.string().optional(), + result: z + .enum(['true', 'false']) + .optional() + .transform((val) => (val === undefined ? undefined : val === 'true')), +}); + +export type WifiAuthLogListQuery = z.infer; +export type WifiAuthLog = z.infer; diff --git a/packages/api/src/domains/wifi/radius.ts b/packages/api/src/domains/wifi/radius.ts new file mode 100644 index 00000000..19cf2c5b --- /dev/null +++ b/packages/api/src/domains/wifi/radius.ts @@ -0,0 +1,26 @@ +import z from 'zod'; + +/** Payload sent by FreeRADIUS to authorize a WiFi client. */ +export const radiusAuthorizeRequestSchema = z.object({ + destination: z.string().optional(), + IP: z.string().optional(), + NAS: z.string().optional(), + password: z.string().optional(), + sharedSecret: z.string().optional(), + source: z.string(), + username: z.string(), +}); + +/** RADIUS control attributes returned for an accepted client. */ +export const radiusAuthorizeResponseSchema = z.object({ + 'Cleartext-Password': z.string(), + 'control:Cleartext-Password': z.string(), + 'Session-Timeout': z.number().int(), +}); + +export type RadiusAuthorizeRequest = z.infer< + typeof radiusAuthorizeRequestSchema +>; +export type RadiusAuthorizeResponse = z.infer< + typeof radiusAuthorizeResponseSchema +>; diff --git a/packages/api/src/domains/wifi/self.ts b/packages/api/src/domains/wifi/self.ts new file mode 100644 index 00000000..8b249226 --- /dev/null +++ b/packages/api/src/domains/wifi/self.ts @@ -0,0 +1,63 @@ +import z from 'zod'; + +export const wifiSelfDeviceSchema = z.object({ + banned: z.boolean(), + id: z.uuid(), + lastActiveAt: z.date().nullable(), + macAddress: z.string(), + nickname: z.string().nullable(), + reportedHostname: z.string().nullable(), +}); + +export const wifiSpeedLimitInfoSchema = z.object({ + downloadSpeedMbps: z.number().int().nullable(), + roleName: z.string().nullable(), + source: z.enum(['override', 'role', 'wlan_default']), + speedProfileId: z.string().nullable(), + uploadSpeedMbps: z.number().int().nullable(), +}); + +export const wifiSelfSchema = z.object({ + banned: z.boolean(), + devices: z.array(wifiSelfDeviceSchema), + speedLimit: wifiSpeedLimitInfoSchema, + username: z.string(), +}); + +export const wifiSelfDeviceUpdateSchema = z.object({ + nickname: z + .string() + .max(100) + .nullable() + .transform((v) => (v === '' ? null : v)), +}); + +export const wifiSelfPasswordChangeSchema = z.object({ + newPassword: z.string().min(8).max(256), +}); + +export const wifiSelfPasswordChangeResponseSchema = z.object({ + success: z.boolean(), +}); + +export const wifiSelfCreateSchema = z.object({ + password: z.string().min(8).max(256), +}); + +export const wifiSelfCreateResponseSchema = z.object({ + wifi: wifiSelfSchema, +}); + +export type WifiSelf = z.infer; +export type WifiSelfDevice = z.infer; +export type WifiSpeedLimitInfo = z.infer; +export type WifiSelfPasswordChange = z.infer< + typeof wifiSelfPasswordChangeSchema +>; +export type WifiSelfPasswordChangeResponse = z.infer< + typeof wifiSelfPasswordChangeResponseSchema +>; +export type WifiSelfCreate = z.infer; +export type WifiSelfCreateResponse = z.infer< + typeof wifiSelfCreateResponseSchema +>; diff --git a/packages/api/src/domains/wifi/stats.ts b/packages/api/src/domains/wifi/stats.ts new file mode 100644 index 00000000..7cf2b45a --- /dev/null +++ b/packages/api/src/domains/wifi/stats.ts @@ -0,0 +1,16 @@ +import z from 'zod'; + +export const wifiAuthSeriesPointSchema = z.object({ + accepted: z.number().int(), + date: z.string(), + rejected: z.number().int(), +}); + +export const wifiStatsOverviewSchema = z.object({ + activeDevices: z.number().int(), + authSeries: z.array(wifiAuthSeriesPointSchema), + totalDevices: z.number().int(), + totalUsers: z.number().int(), +}); + +export type WifiStatsOverview = z.infer; diff --git a/packages/api/src/domains/wifi/status.ts b/packages/api/src/domains/wifi/status.ts new file mode 100644 index 00000000..463a8114 --- /dev/null +++ b/packages/api/src/domains/wifi/status.ts @@ -0,0 +1,8 @@ +import z from 'zod'; + +export const wifiStatusResponseSchema = z.object({ + enabled: z.boolean(), + ssid: z.string().nullable(), +}); + +export type WifiStatusResponse = z.infer; diff --git a/packages/api/src/permissions.ts b/packages/api/src/permissions.ts index 373b81e5..c7c23f34 100644 --- a/packages/api/src/permissions.ts +++ b/packages/api/src/permissions.ts @@ -32,6 +32,8 @@ export const permissions = { teacherManage: 'teacher:manage', usersManage: 'users:manage', usersRead: 'users:read', + wifiRead: 'wifi:read', + wifiWrite: 'wifi:write', } as const; export type Permission = (typeof permissions)[keyof typeof permissions]; diff --git a/packages/ui/src/components/password-input.tsx b/packages/ui/src/components/password-input.tsx new file mode 100644 index 00000000..8b9ac869 --- /dev/null +++ b/packages/ui/src/components/password-input.tsx @@ -0,0 +1,57 @@ +import { Eye, EyeOff } from 'lucide-react'; +import type { ComponentProps } from 'react'; +import { useState } from 'react'; +import { cn } from '../lib/utils'; +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, +} from './input-group'; + +type PasswordInputProps = ComponentProps<'input'> & { + hidePasswordLabel?: string; + showPasswordLabel?: string; +}; + +function PasswordInput({ + className, + disabled, + hidePasswordLabel = 'Hide password', + showPasswordLabel = 'Show password', + ...props +}: PasswordInputProps) { + const [showPassword, setShowPassword] = useState(false); + + return ( + + + + setShowPassword((prev) => !prev)} + onMouseDown={(e) => e.preventDefault()} + size="icon-xs" + type="button" + > + {showPassword ? ( + + ) : ( + + )} + + + + ); +} + +export { PasswordInput }; From 62ebf163ab500e287e2fc62c4b38c5c66e425cfb Mon Sep 17 00:00:00 2001 From: Tamas Vince Date: Thu, 8 Oct 2026 11:59:55 +0200 Subject: [PATCH 22/32] feat(iris): merge the settings page into a sectioned settings dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The settings dialog and the `/settings` page duplicated the same preference controls, and the API-key table needed ~900px — more than either surface had — so it scrolled sideways on desktop and mobile alike. One dialog now owns every setting, in the sidebar-in-dialog shape: a section rail on the left, the active pane on the right, and on a phone the pane replaces the rail with a back button. It is mounted once at the app root behind a small store, so the profile menu and the notification viewer's "change cohort" button open the same instance instead of mounting their own. The `/settings` route and the duplicate my-groups card are gone. The API-key list is a list, not a table: a row shows the name, state, prefix and the three timestamps that matter, and rename/enable/delete moved into an actions menu. Nothing overflows at any width. --- apps/iris/public/locales/en/translation.json | 3 +- apps/iris/public/locales/hu/translation.json | 3 +- .../src/components/api-keys/api-keys-card.tsx | 239 ++++---- apps/iris/src/components/navbar.tsx | 18 +- .../components/notification-viewer-dialog.tsx | 5 +- .../src/components/settings-dialog-store.ts | 65 +++ apps/iris/src/components/settings-dialog.tsx | 534 +++++------------- .../src/components/settings/api-keys-pane.tsx | 12 + .../src/components/settings/general-pane.tsx | 317 +++++++++++ .../groups-pane.tsx} | 18 +- .../settings/notifications-pane.tsx | 130 +++++ apps/iris/src/routeTree.gen.ts | 21 - apps/iris/src/routes/__root.tsx | 4 + apps/iris/src/routes/_private/settings.tsx | 230 -------- 14 files changed, 807 insertions(+), 792 deletions(-) create mode 100644 apps/iris/src/components/settings-dialog-store.ts create mode 100644 apps/iris/src/components/settings/api-keys-pane.tsx create mode 100644 apps/iris/src/components/settings/general-pane.tsx rename apps/iris/src/components/{timetable/my-groups-card.tsx => settings/groups-pane.tsx} (73%) create mode 100644 apps/iris/src/components/settings/notifications-pane.tsx delete mode 100644 apps/iris/src/routes/_private/settings.tsx diff --git a/apps/iris/public/locales/en/translation.json b/apps/iris/public/locales/en/translation.json index 112a8fff..54bac9c4 100644 --- a/apps/iris/public/locales/en/translation.json +++ b/apps/iris/public/locales/en/translation.json @@ -704,7 +704,8 @@ "channelsEnabled": "Enable notifications", "saveSuccess": "Preferences saved", "saveError": "Failed to save preferences", - "loadError": "Failed to load preferences" + "loadError": "Failed to load preferences", + "languageChangeError": "Could not change the language" }, "unsubscribe": { "title": "Notification Preferences", diff --git a/apps/iris/public/locales/hu/translation.json b/apps/iris/public/locales/hu/translation.json index a8c57bbb..fdc7765a 100644 --- a/apps/iris/public/locales/hu/translation.json +++ b/apps/iris/public/locales/hu/translation.json @@ -704,7 +704,8 @@ "channelsEnabled": "Értesítések engedélyezése", "saveSuccess": "Beállítások mentve", "saveError": "Nem sikerült menteni a beállításokat", - "loadError": "Nem sikerült betölteni a beállításokat" + "loadError": "Nem sikerült betölteni a beállításokat", + "languageChangeError": "Nem sikerült megváltoztatni a nyelvet" }, "unsubscribe": { "title": "Értesítési beállítások", diff --git a/apps/iris/src/components/api-keys/api-keys-card.tsx b/apps/iris/src/components/api-keys/api-keys-card.tsx index 58721b81..fb5c4e7a 100644 --- a/apps/iris/src/components/api-keys/api-keys-card.tsx +++ b/apps/iris/src/components/api-keys/api-keys-card.tsx @@ -10,16 +10,15 @@ import { CardHeader, CardTitle, } from '@filcdev/ui/components/card'; -import { Skeleton } from '@filcdev/ui/components/skeleton'; import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@filcdev/ui/components/table'; -import { Plus } from 'lucide-react'; + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@filcdev/ui/components/dropdown-menu'; +import { Empty } from '@filcdev/ui/components/empty'; +import { Skeleton } from '@filcdev/ui/components/skeleton'; +import { KeyRound, MoreHorizontal, Plus } from 'lucide-react'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { CreateApiKeyDialog } from '@/components/api-keys/create-api-key-dialog'; @@ -49,6 +48,97 @@ function formatTimestamp( return formatLocalizedDate(date, language); } +/** + * One key as a list row. + * + * A table cannot work here: seven columns need ~900px and the settings dialog + * gives the pane about half of that, so every row would scroll sideways. The + * timestamps that matter are the two an operator reads — created and last used + * — and the rest sit in the actions menu. + */ +function ApiKeyRow({ + apiKey, + onRename, + onToggle, + onDelete, + isToggling, + isDeleting, +}: { + apiKey: UserApiKey; + onRename: () => void; + onToggle: () => void; + onDelete: () => void; + isToggling: boolean; + isDeleting: boolean; +}) { + const { t, i18n } = useTranslation(); + + return ( +
  • +
    +
    + + {apiKey.name ?? t('apiKeys.unnamed')} + + + {t(apiKey.enabled ? 'apiKeys.enabled' : 'apiKeys.disabled')} + +
    + + {apiKey.start ?? apiKey.prefix ?? t('apiKeys.unknownKey')} + + + {t('apiKeys.created')}:{' '} + {formatTimestamp( + apiKey.createdAt, + i18n.language, + t('apiKeys.unknownDate') + )} + {' · '} + {t('apiKeys.lastUsed')}:{' '} + {formatTimestamp( + apiKey.lastRequest, + i18n.language, + t('apiKeys.neverUsed') + )} + {' · '} + {t('apiKeys.expires')}:{' '} + {formatTimestamp(apiKey.expiresAt, i18n.language, t('apiKeys.never'))} + +
    + + + + + } + /> + + + {t('apiKeys.rename')} + + + {t(apiKey.enabled ? 'apiKeys.disable' : 'apiKeys.enable')} + + + {t('apiKeys.delete')} + + + +
  • + ); +} + /** * The signed-in user's own API keys: list, create, rename, toggle and revoke. * @@ -57,7 +147,7 @@ function formatTimestamp( * beyond being signed in, which the `_private` tree already guarantees. */ export function ApiKeysCard() { - const { t, i18n } = useTranslation(); + const { t } = useTranslation(); const { data: session } = useSession(); const [isCreateOpen, setIsCreateOpen] = useState(false); const [renaming, setRenaming] = useState(null); @@ -102,108 +192,33 @@ export function ApiKeysCard() { {t('apiKeys.loadError')} )} - {!(apiKeysQuery.isLoading || apiKeysQuery.isError) && ( -
    - - - - {t('apiKeys.name')} - {t('apiKeys.key')} - {t('apiKeys.created')} - {t('apiKeys.expires')} - {t('apiKeys.lastUsed')} - {t('apiKeys.status')} - {t('apiKeys.actions')} - - - - {apiKeys.length === 0 && ( - - - {t('apiKeys.noKeys')} - - - )} - {apiKeys.map((apiKey) => ( - - - {apiKey.name ?? t('apiKeys.unnamed')} - - - {apiKey.start ?? apiKey.prefix ?? t('apiKeys.unknownKey')} - - - {formatTimestamp( - apiKey.createdAt, - i18n.language, - t('apiKeys.unknownDate') - )} - - - {formatTimestamp( - apiKey.expiresAt, - i18n.language, - t('apiKeys.never') - )} - - - {formatTimestamp( - apiKey.lastRequest, - i18n.language, - t('apiKeys.neverUsed') - )} - - - - {t( - apiKey.enabled - ? 'apiKeys.enabled' - : 'apiKeys.disabled' - )} - - - -
    - - - -
    -
    -
    - ))} -
    -
    -
    - )} + {!(apiKeysQuery.isLoading || apiKeysQuery.isError) && + (apiKeys.length === 0 ? ( + } + title={t('apiKeys.title')} + /> + ) : ( +
      + {apiKeys.map((apiKey) => ( + handleDelete(apiKey)} + onRename={() => setRenaming(apiKey)} + onToggle={() => + updateApiKey.mutate({ + enabled: !apiKey.enabled, + keyId: apiKey.id, + }) + } + /> + ))} +
    + ))} {isCreateOpen && ( import('@/components/bug-report-dialog').then((m) => ({ default: m.BugReportDialog, })); -const loadSettingsDialog = () => - import('@/components/settings-dialog').then((m) => ({ - default: m.SettingsDialog, - })); type NavbarProps = { children?: ReactNode; @@ -96,7 +94,6 @@ export function Navbar({ const { t } = useTranslation(); const queryClient = useQueryClient(); const { data, isPending } = useSession(); - const [settingsOpen, setSettingsOpen] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const mobileNavId = useId(); @@ -239,7 +236,7 @@ export function Navbar({ {t('doorlock.manage-cards')} - setSettingsOpen(true)}> + openSettings()}> {t('preferences.title')} @@ -309,9 +306,6 @@ export function Navbar({
    )} - {settingsOpen && ( - - )} ); } diff --git a/apps/iris/src/components/notification-viewer-dialog.tsx b/apps/iris/src/components/notification-viewer-dialog.tsx index 7f587240..29764786 100644 --- a/apps/iris/src/components/notification-viewer-dialog.tsx +++ b/apps/iris/src/components/notification-viewer-dialog.tsx @@ -7,10 +7,10 @@ import { DialogHeader, DialogTitle, } from '@filcdev/ui/components/dialog'; -import { useNavigate } from '@tanstack/react-router'; import { MailCheck, MailX } from 'lucide-react'; import { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; +import { openSettings } from '@/components/settings-dialog-store'; import { type NotificationItem, useMarkNotificationRead, @@ -29,7 +29,6 @@ export function NotificationViewerDialog({ notification, }: NotificationViewerDialogProps) { const { t } = useTranslation(); - const navigate = useNavigate(); const markReadMutation = useMarkNotificationRead(); useEffect(() => { @@ -75,7 +74,7 @@ export function NotificationViewerDialog({
    - ); -} - -type SettingsDialogProps = { - open: boolean; - onOpenChange: (open: boolean) => void; +type PaneProps = { + /** False while the dialog is closed: the pane stops fetching. */ + active: boolean; + onClose: () => void; }; -/** Language, theme, cohort and timetable group-display preferences. */ -function GeneralSettingsCard({ - cohortQuery, - language, - onGroupDisplayChange, - onLanguageChange, - onSelectedCohortIdChange, - onThemeChange, - selectedCohortId, - theme, - timetableGroupDisplay, -}: { - cohortQuery: UseQueryResult; - language: string; - onGroupDisplayChange: (value: 'highlight' | 'hide') => void; - onLanguageChange: (value: string | null) => void; - onSelectedCohortIdChange: (value: string | null) => void; - onThemeChange: (value: string) => void; - selectedCohortId: string | null; - theme: string; - timetableGroupDisplay: 'highlight' | 'hide'; -}) { - const { t } = useTranslation(); - const languageItems = [ - { label: 'Magyar', value: 'hu' }, - { label: 'English', value: 'en' }, - ]; - const themeItems = [ - { label: t('preferences.themeLight'), value: 'light' }, - { label: t('preferences.themeDark'), value: 'dark' }, - { label: t('preferences.themeSystem'), value: 'system' }, - ]; - const cohortItems = [ - { label: t('cohort.noClass'), value: NO_CLASS_VALUE }, - ...(cohortQuery.data ?? []).map((cohort) => ({ - label: cohort.name, - value: cohort.id, - })), - ]; - - return ( - - - {t('preferences.general')} - - -
    - {t('preferences.language')} - -
    -
    - {t('preferences.theme')} - -
    - -
    -
    - {t('preferences.cohort')} - {cohortQuery.isLoading ? ( - - ) : ( - - )} -
    - {cohortQuery.isError ? ( - - - {t('cohort.errorLoading', { - message: `${cohortQuery.error ?? ''}`, - })} - - - ) : null} -
    - - -
    -
    - ); -} +const PANES: Record> = { + apiKeys: ApiKeysPane, + general: GeneralPane, + groups: GroupsPane, + notifications: NotificationsPane, +}; -/** Notification channel toggle plus the per-type preference checkboxes. */ -function NotificationSettingsCard({ - channelsId, - onTogglePref, - prefs, -}: { - channelsId: string; - onTogglePref: (key: string) => void; - prefs: Record; -}) { +export function SettingsDialog() { const { t } = useTranslation(); - return ( - - - {t('preferences.notifications')} - - {t('preferences.notificationsDescription')} - - - -
    - - onTogglePref('channelsEnabled')} - /> -
    - {NOTIFICATION_TYPES.map(({ key, labelKey }) => ( -
    - - onTogglePref(key)} - /> -
    - ))} -
    -
    - ); -} - -export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) { - const { i18n, t } = useTranslation(); - const [, setCookie] = useCookies(['filc.language']); - const channelsId = useId(); - const { setTheme: applyTheme } = useTheme(); - const { data: session } = useSession(); - const [language, setLanguage] = useState('hu'); - const [theme, setTheme] = useState('system'); - const [timetableView, setTimetableView] = useState('class'); - const [timetableGroupDisplay, setTimetableGroupDisplay] = useState< - 'highlight' | 'hide' - >('highlight'); - const [selectedCohortId, setSelectedCohortId] = useState(null); - const [prefs, setPrefs] = useState({ - announcement: true, - blogPost: false, - channelsEnabled: true, - doorlockCardUsed: false, - movedLesson: true, - substitution: true, - systemMessage: true, - }); - - const { - data: settingsData, - isLoading, - isError, - isSuccess, - } = useNotificationSettings(open); - - useEffect(() => { - if (!(isSuccess && settingsData)) { - return; - } - setLanguage(settingsData.language); - setTheme(settingsData.theme); - setTimetableView(settingsData.timetableView); - setTimetableGroupDisplay( - settingsData.timetableGroupDisplay === 'hide' ? 'hide' : 'highlight' - ); - setPrefs(settingsData.notificationPreferences); - }, [isSuccess, settingsData]); - - const cohortQuery = useQuery({ - ...orpc.cohort.cohort.queryOptions(), - enabled: open, - select: (data) => sortCohorts(data), - }); + const { open, section } = useSettingsDialog(); + const isMobile = useIsMobile(); + // On a phone the nav is a list and the pane replaces it; on a wider screen + // both are visible side by side. + const [mobilePaneOpen, setMobilePaneOpen] = useState(false); useEffect(() => { + // Returning to the nav on close means reopening always starts at the list, + // and a section the caller targeted is not skipped on the next open. if (!open) { - return; + setMobilePaneOpen(false); } + }, [open]); - setSelectedCohortId(session?.user?.cohortId ?? null); - }, [open, session?.user?.cohortId]); - - const updateSettingsMutation = useUpdateNotificationSettings({ - onSaved: () => { - applyTheme(theme); - onOpenChange(false); - }, - updateCohort: async () => { - const currentCohortId = session?.user?.cohortId ?? null; - const newCohortId = - selectedCohortId === NO_CLASS_VALUE ? null : selectedCohortId; - if (cohortQuery.isSuccess && newCohortId !== currentCohortId) { - try { - await authClient.updateUser({ cohortId: newCohortId }); - } catch { - throw new Error('Failed to update cohort'); - } - } - }, - }); - - const saveSettings = () => - updateSettingsMutation.mutate({ - language, - notificationPreferences: prefs, - theme, - timetableGroupDisplay, - timetableView, - }); - - const togglePref = (key: string) => { - setPrefs((prev) => ({ ...prev, [key]: !prev[key as keyof typeof prev] })); + const activeNav = NAV.find((item) => item.section === section) ?? { + icon: SlidersHorizontal, + labelKey: 'preferences.general', + section: 'general' as const, }; - - const handleLanguageChange = (value: string | null) => { - if (!value) { - return; - } - - setLanguage(value); - i18n.changeLanguage(value).catch(() => { - toast.error(t('preferences.languageChangeError')); - }); - setCookie('filc.language', value, { sameSite: 'lax' }); - if (typeof document !== 'undefined') { - document.documentElement.lang = value; - } + const Pane = PANES[section]; + // The nav is always visible on a wider screen; on a phone it is replaced by + // the pane, which is why picking a section has to push the pane on there. + const showNav = !(isMobile && mobilePaneOpen); + const showPane = !isMobile || mobilePaneOpen; + + const goToSection = (next: SettingsSection) => { + selectSettingsSection(next); + setMobilePaneOpen(true); }; - const ready = !(isLoading || isError); - // The group picker is scoped to the user's *persisted* cohort, so memberships - // are never saved for a cohort the user has only drafted in this dialog. - const persistedCohortId = session?.user?.cohortId ?? null; - return ( - - - - {t('preferences.title')} - -
    - {isLoading ? ( -
    - {[0, 1, 2].map((i) => ( - - ))} -
    - ) : null} - {isError ? ( - - {t('preferences.loadError')} - - ) : null} - {ready ? ( - <> - - - - - - - + + ))} + + + )} + + {showPane && ( +
    +
    + {isMobile && ( + )} - {t('common.accept')} - - - ) : null} +

    + {t(activeNav.labelKey)} +

    +
    + +
    + +
    +
    + )}
    diff --git a/apps/iris/src/components/settings/api-keys-pane.tsx b/apps/iris/src/components/settings/api-keys-pane.tsx new file mode 100644 index 00000000..850da2d0 --- /dev/null +++ b/apps/iris/src/components/settings/api-keys-pane.tsx @@ -0,0 +1,12 @@ +import { ApiKeysCard } from '@/components/api-keys/api-keys-card'; + +/** + * The signed-in user's own API keys. + * + * The card owns its query and its dialogs, so the pane is just the card — but + * it stays a pane so the dialog's nav has a uniform shape and this section can + * grow without touching the dialog. + */ +export function ApiKeysPane() { + return ; +} diff --git a/apps/iris/src/components/settings/general-pane.tsx b/apps/iris/src/components/settings/general-pane.tsx new file mode 100644 index 00000000..af8f9db0 --- /dev/null +++ b/apps/iris/src/components/settings/general-pane.tsx @@ -0,0 +1,317 @@ +import { authClient, useSession } from '@filcdev/auth/client'; +import { Alert, AlertTitle } from '@filcdev/ui/components/alert'; +import { Button } from '@filcdev/ui/components/button'; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from '@filcdev/ui/components/card'; +import { + Select, + SelectTrigger, + SelectValue, +} from '@filcdev/ui/components/select'; +import { Skeleton } from '@filcdev/ui/components/skeleton'; +import { Spinner } from '@filcdev/ui/components/spinner'; +import { type UseQueryResult, useQuery } from '@tanstack/react-query'; +import { useTheme } from 'next-themes'; +import { useEffect, useState } from 'react'; +import { useCookies } from 'react-cookie'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; +import type { CohortItem } from '@/components/timetable/types'; +import { + useNotificationSettings, + useUpdateNotificationSettings, +} from '@/hooks/notifications'; +import { sortCohorts } from '@/utils/cohort'; +import { orpc } from '@/utils/orpc'; + +// Sentinel value for the "no class" choice in the cohort onValueChange(next as 'highlight' | 'hide')} + value={value} + > + + + + + + ); +} + +function GeneralSettingsCard({ + cohortQuery, + language, + onGroupDisplayChange, + onLanguageChange, + onSelectedCohortIdChange, + onThemeChange, + selectedCohortId, + theme, + timetableGroupDisplay, +}: { + cohortQuery: UseQueryResult; + language: string; + onGroupDisplayChange: (value: 'highlight' | 'hide') => void; + onLanguageChange: (value: string | null) => void; + onSelectedCohortIdChange: (value: string | null) => void; + onThemeChange: (value: string) => void; + selectedCohortId: string | null; + theme: string; + timetableGroupDisplay: 'highlight' | 'hide'; +}) { + const { t } = useTranslation(); + const cohortItems = [ + { label: t('cohort.noClass'), value: NO_CLASS_VALUE }, + ...(cohortQuery.data ?? []).map((cohort) => ({ + label: cohort.name, + value: cohort.id, + })), + ]; + + return ( + + + {t('preferences.general')} + + +
    + {t('preferences.language')} + +
    + +
    + {t('preferences.theme')} + +
    + +
    +
    + {t('preferences.cohort')} + {cohortQuery.isLoading ? ( + + ) : ( + + )} +
    +

    + {t('preferences.cohortDescription')} +

    + {cohortQuery.isError ? ( + + + {t('cohort.errorLoading', { + message: `${cohortQuery.error ?? ''}`, + })} + + + ) : null} +
    + + +
    +
    + ); +} + +/** + * Language, theme, cohort and timetable group-display preferences. + * + * `active` is the dialog's open state: the settings query is session-scoped, so + * it stays disabled while the dialog is closed. + */ +export function GeneralPane({ + active, + onClose, +}: { + active: boolean; + onClose: () => void; +}) { + const { i18n, t } = useTranslation(); + const [, setCookie] = useCookies(['filc.language']); + const { setTheme: applyTheme } = useTheme(); + const { data: session } = useSession(); + const [language, setLanguage] = useState('hu'); + const [theme, setTheme] = useState('system'); + const [timetableView, setTimetableView] = useState('class'); + const [timetableGroupDisplay, setTimetableGroupDisplay] = useState< + 'highlight' | 'hide' + >('highlight'); + const [selectedCohortId, setSelectedCohortId] = useState(null); + + const settingsQuery = useNotificationSettings(active); + + const cohortQuery = useQuery({ + ...orpc.cohort.cohort.queryOptions(), + enabled: active, + select: (data) => sortCohorts(data), + }); + + useEffect(() => { + const settings = settingsQuery.data; + if (!settings) { + return; + } + setLanguage(settings.language); + setTheme(settings.theme); + setTimetableView(settings.timetableView); + setTimetableGroupDisplay( + settings.timetableGroupDisplay === 'hide' ? 'hide' : 'highlight' + ); + }, [settingsQuery.data]); + + useEffect(() => { + if (!active) { + return; + } + setSelectedCohortId(session?.user?.cohortId ?? null); + }, [active, session?.user?.cohortId]); + + const saveSettings = useUpdateNotificationSettings({ + onSaved: () => { + applyTheme(theme); + onClose(); + }, + updateCohort: async () => { + const currentCohortId = session?.user?.cohortId ?? null; + const newCohortId = + selectedCohortId === NO_CLASS_VALUE ? null : selectedCohortId; + if (cohortQuery.isSuccess && newCohortId !== currentCohortId) { + try { + await authClient.updateUser({ cohortId: newCohortId }); + } catch { + throw new Error('Failed to update cohort'); + } + } + }, + }); + + const handleLanguageChange = (value: string | null) => { + if (!value) { + return; + } + + setLanguage(value); + i18n.changeLanguage(value).catch(() => { + toast.error(t('preferences.languageChangeError')); + }); + setCookie('filc.language', value, { sameSite: 'lax' }); + if (typeof document !== 'undefined') { + document.documentElement.lang = value; + } + }; + + if (settingsQuery.isLoading) { + return ( +
    + {[0, 1, 2].map((i) => ( + + ))} +
    + ); + } + + if (settingsQuery.isError) { + return ( + + {t('preferences.loadError')} + + ); + } + + return ( +
    + + + +
    + ); +} diff --git a/apps/iris/src/components/timetable/my-groups-card.tsx b/apps/iris/src/components/settings/groups-pane.tsx similarity index 73% rename from apps/iris/src/components/timetable/my-groups-card.tsx rename to apps/iris/src/components/settings/groups-pane.tsx index ffd2297e..72a77fe3 100644 --- a/apps/iris/src/components/timetable/my-groups-card.tsx +++ b/apps/iris/src/components/settings/groups-pane.tsx @@ -1,3 +1,4 @@ +import { useSession } from '@filcdev/auth/client'; import { Alert, AlertTitle } from '@filcdev/ui/components/alert'; import { Card, @@ -8,19 +9,20 @@ import { } from '@filcdev/ui/components/card'; import { Skeleton } from '@filcdev/ui/components/skeleton'; import { useTranslation } from 'react-i18next'; +import { DivisionGroupPicker } from '@/components/timetable/group-picker'; import { useGroupsForCohort } from '@/hooks/timetable-groups'; -import { DivisionGroupPicker } from './group-picker'; - -type MyGroupsSettingsCardProps = { - cohortId: string | null; -}; /** - * The "My groups" settings card: lets a student pick which group they belong - * to in each division of their class (one group per division). + * The "My groups" pane: lets a student pick which group they belong to in each + * division of their class (one group per division). + * + * The picker is scoped to the user's *persisted* cohort, so a membership is + * never saved for a class the user has only drafted in the General pane. */ -export function MyGroupsSettingsCard({ cohortId }: MyGroupsSettingsCardProps) { +export function GroupsPane() { const { t } = useTranslation(); + const { data: session } = useSession(); + const cohortId = session?.user?.cohortId ?? null; const groupsQuery = useGroupsForCohort(cohortId); const renderContent = () => { diff --git a/apps/iris/src/components/settings/notifications-pane.tsx b/apps/iris/src/components/settings/notifications-pane.tsx new file mode 100644 index 00000000..697e7479 --- /dev/null +++ b/apps/iris/src/components/settings/notifications-pane.tsx @@ -0,0 +1,130 @@ +import { Button } from '@filcdev/ui/components/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@filcdev/ui/components/card'; +import { Checkbox } from '@filcdev/ui/components/checkbox'; +import { Skeleton } from '@filcdev/ui/components/skeleton'; +import { Spinner } from '@filcdev/ui/components/spinner'; +import { useEffect, useId, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + useNotificationSettings, + useUpdateNotificationSettings, +} from '@/hooks/notifications'; + +const NOTIFICATION_TYPES = [ + { + key: 'substitution', + labelKey: 'notifications.types.substitution' as const, + }, + { key: 'movedLesson', labelKey: 'notifications.types.movedLesson' as const }, + { + key: 'announcement', + labelKey: 'notifications.types.announcement' as const, + }, + { + key: 'systemMessage', + labelKey: 'notifications.types.systemMessage' as const, + }, + { key: 'blogPost', labelKey: 'notifications.types.blogPost' as const }, + { + key: 'doorlockCardUsed', + labelKey: 'notifications.types.doorlockCardUsed' as const, + }, +]; + +const DEFAULT_PREFS = { + announcement: true, + blogPost: false, + channelsEnabled: true, + doorlockCardUsed: false, + movedLesson: true, + substitution: true, + systemMessage: true, +}; + +/** Channel toggle plus the per-type preference checkboxes. */ +export function NotificationsPane({ + active, + onClose, +}: { + active: boolean; + onClose: () => void; +}) { + const { t } = useTranslation(); + const channelsId = useId(); + const [prefs, setPrefs] = useState>(DEFAULT_PREFS); + + const settingsQuery = useNotificationSettings(active); + const saveSettings = useUpdateNotificationSettings({ onSaved: onClose }); + + useEffect(() => { + if (settingsQuery.data) { + setPrefs(settingsQuery.data.notificationPreferences); + } + }, [settingsQuery.data]); + + const togglePref = (key: string) => { + setPrefs((previous) => ({ ...previous, [key]: !previous[key] })); + }; + + if (settingsQuery.isLoading) { + return ; + } + + return ( +
    + + + {t('preferences.notifications')} + + {t('preferences.notificationsDescription')} + + + +
    + + togglePref('channelsEnabled')} + /> +
    + {NOTIFICATION_TYPES.map(({ key, labelKey }) => ( +
    + + togglePref(key)} + /> +
    + ))} +
    +
    + + +
    + ); +} diff --git a/apps/iris/src/routeTree.gen.ts b/apps/iris/src/routeTree.gen.ts index aca72272..1dc88913 100644 --- a/apps/iris/src/routeTree.gen.ts +++ b/apps/iris/src/routeTree.gen.ts @@ -12,7 +12,6 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as PrivateRouteRouteImport } from './routes/_private/route' import { Route as PublicRouteRouteImport } from './routes/_public/route' import { Route as PrivateAdminRouteRouteImport } from './routes/_private/admin/route' -import { Route as PrivateSettingsRouteImport } from './routes/_private/settings' import { Route as PrivateWifiRouteImport } from './routes/_private/wifi' import { Route as PublicIndexRouteImport } from './routes/_public/index' import { Route as PublicPolicyRouteImport } from './routes/_public/policy' @@ -66,11 +65,6 @@ const PrivateAdminRouteRoute = PrivateAdminRouteRouteImport.update({ path: '/admin', getParentRoute: () => PrivateRouteRoute, } as any) -const PrivateSettingsRoute = PrivateSettingsRouteImport.update({ - id: '/settings', - path: '/settings', - getParentRoute: () => PrivateRouteRoute, -} as any) const PrivateWifiRoute = PrivateWifiRouteImport.update({ id: '/wifi', path: '/wifi', @@ -292,7 +286,6 @@ const PrivateAdminWifiUsersRoute = PrivateAdminWifiUsersRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof PublicIndexRoute '/admin': typeof PrivateAdminRouteRouteWithChildren - '/settings': typeof PrivateSettingsRoute '/wifi': typeof PrivateWifiRoute '/policy': typeof PublicPolicyRoute '/subs': typeof PublicSubsRoute @@ -335,7 +328,6 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof PublicIndexRoute '/admin': typeof PrivateAdminRouteRouteWithChildren - '/settings': typeof PrivateSettingsRoute '/wifi': typeof PrivateWifiRoute '/policy': typeof PublicPolicyRoute '/subs': typeof PublicSubsRoute @@ -380,7 +372,6 @@ export interface FileRoutesById { '/_private': typeof PrivateRouteRouteWithChildren '/_public': typeof PublicRouteRouteWithChildren '/_private/admin': typeof PrivateAdminRouteRouteWithChildren - '/_private/settings': typeof PrivateSettingsRoute '/_private/wifi': typeof PrivateWifiRoute '/_public/policy': typeof PublicPolicyRoute '/_public/subs': typeof PublicSubsRoute @@ -426,7 +417,6 @@ export interface FileRouteTypes { fullPaths: | '/' | '/admin' - | '/settings' | '/wifi' | '/policy' | '/subs' @@ -469,7 +459,6 @@ export interface FileRouteTypes { to: | '/' | '/admin' - | '/settings' | '/wifi' | '/policy' | '/subs' @@ -513,7 +502,6 @@ export interface FileRouteTypes { | '/_private' | '/_public' | '/_private/admin' - | '/_private/settings' | '/_private/wifi' | '/_public/policy' | '/_public/subs' @@ -586,13 +574,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PrivateAdminRouteRouteImport parentRoute: typeof PrivateRouteRoute } - '/_private/settings': { - id: '/_private/settings' - path: '/settings' - fullPath: '/settings' - preLoaderRoute: typeof PrivateSettingsRouteImport - parentRoute: typeof PrivateRouteRoute - } '/_private/wifi': { id: '/_private/wifi' path: '/wifi' @@ -944,14 +925,12 @@ const PrivateAdminRouteRouteWithChildren = interface PrivateRouteRouteChildren { PrivateAdminRouteRoute: typeof PrivateAdminRouteRouteWithChildren - PrivateSettingsRoute: typeof PrivateSettingsRoute PrivateWifiRoute: typeof PrivateWifiRoute PrivateCardsIndexRoute: typeof PrivateCardsIndexRoute } const PrivateRouteRouteChildren: PrivateRouteRouteChildren = { PrivateAdminRouteRoute: PrivateAdminRouteRouteWithChildren, - PrivateSettingsRoute: PrivateSettingsRoute, PrivateWifiRoute: PrivateWifiRoute, PrivateCardsIndexRoute: PrivateCardsIndexRoute, } diff --git a/apps/iris/src/routes/__root.tsx b/apps/iris/src/routes/__root.tsx index e2a55f05..71171e53 100644 --- a/apps/iris/src/routes/__root.tsx +++ b/apps/iris/src/routes/__root.tsx @@ -11,6 +11,7 @@ import { ThemeProvider } from 'next-themes'; import { useEffect, useState } from 'react'; import { CookiesProvider } from 'react-cookie'; import { I18nextProvider } from 'react-i18next'; +import { SettingsDialog } from '@/components/settings-dialog'; import { SystemMessageBanner } from '@/components/system-message-banner'; import { CookiePopup } from '@/components/util/cookie-popup'; import { useNotificationSettings } from '@/hooks/notifications'; @@ -127,6 +128,9 @@ function AppShell() { <> + {/* Mounted once for the whole app: the profile menu, the notification + viewer and any future entry point all open this same dialog. */} + diff --git a/apps/iris/src/routes/_private/settings.tsx b/apps/iris/src/routes/_private/settings.tsx deleted file mode 100644 index b66eccab..00000000 --- a/apps/iris/src/routes/_private/settings.tsx +++ /dev/null @@ -1,230 +0,0 @@ -import { Alert, AlertTitle } from '@filcdev/ui/components/alert'; -import { Button } from '@filcdev/ui/components/button'; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@filcdev/ui/components/card'; -import { Checkbox } from '@filcdev/ui/components/checkbox'; -import { - Select, - SelectTrigger, - SelectValue, -} from '@filcdev/ui/components/select'; -import { Skeleton } from '@filcdev/ui/components/skeleton'; -import { Spinner } from '@filcdev/ui/components/spinner'; -import { createFileRoute } from '@tanstack/react-router'; -import { useTheme } from 'next-themes'; -import { useEffect, useId, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { ApiKeysCard } from '@/components/api-keys/api-keys-card'; -import { - useNotificationSettings, - useUpdateNotificationSettings, -} from '@/hooks/notifications'; - -const NOTIFICATION_TYPES = [ - { - key: 'substitution', - labelKey: 'notifications.types.substitution' as const, - }, - { key: 'movedLesson', labelKey: 'notifications.types.movedLesson' as const }, - { - key: 'announcement', - labelKey: 'notifications.types.announcement' as const, - }, - { - key: 'systemMessage', - labelKey: 'notifications.types.systemMessage' as const, - }, - { key: 'blogPost', labelKey: 'notifications.types.blogPost' as const }, - { - key: 'doorlockCardUsed', - labelKey: 'notifications.types.doorlockCardUsed' as const, - }, -]; - -export const Route = createFileRoute('/_private/settings')({ - component: SettingsPage, -}); - -function SettingsPage() { - const { t } = useTranslation(); - const channelsId = useId(); - const { setTheme: applyTheme } = useTheme(); - const [language, setLanguage] = useState('hu'); - const [theme, setTheme] = useState('system'); - const [timetableView, setTimetableView] = useState('class'); - const [prefs, setPrefs] = useState({ - announcement: true, - blogPost: false, - channelsEnabled: true, - doorlockCardUsed: false, - movedLesson: true, - substitution: true, - systemMessage: true, - }); - - const { data: settings, isLoading, isError } = useNotificationSettings(true); - - useEffect(() => { - if (!settings) { - return; - } - setLanguage(settings.language); - setTheme(settings.theme); - setTimetableView(settings.timetableView); - setPrefs(settings.notificationPreferences); - }, [settings]); - - const saveMutation = useUpdateNotificationSettings({ - onSaved: () => { - applyTheme(theme); - }, - }); - - const togglePref = (key: string) => { - setPrefs((prev) => ({ ...prev, [key]: !prev[key as keyof typeof prev] })); - }; - - const handleSelectChange = ( - setter: (v: string) => void - ): ((value: string | null) => void) => { - return (value: string | null) => { - if (value) { - setter(value); - } - }; - }; - - if (isLoading) { - return ( -
    - {[0, 1, 2].map((i) => ( - - ))} -
    - ); - } - - if (isError) { - return ( -
    - - {t('preferences.loadError')} - -
    - ); - } - - const languageItems = [ - { label: 'Magyar', value: 'hu' }, - { label: 'English', value: 'en' }, - ]; - - const themeItems = [ - { label: t('preferences.themeLight'), value: 'light' }, - { label: t('preferences.themeDark'), value: 'dark' }, - { label: t('preferences.themeSystem'), value: 'system' }, - ]; - - return ( -
    -
    -

    {t('preferences.title')}

    -

    {t('preferences.description')}

    -
    - - - - {t('preferences.general')} - - -
    - {t('preferences.language')} - -
    -
    - {t('preferences.theme')} - -
    -
    -
    - - - - {t('preferences.notifications')} - - {t('preferences.notificationsDescription')} - - - -
    - - togglePref('channelsEnabled')} - /> -
    - {NOTIFICATION_TYPES.map(({ key, labelKey }) => ( -
    - - togglePref(key)} - /> -
    - ))} -
    -
    - - - - -
    - ); -} From 172febeedf419546de94518b586154e2d3328017 Mon Sep 17 00:00:00 2001 From: Tamas Vince Date: Thu, 8 Oct 2026 12:27:18 +0200 Subject: [PATCH 23/32] feat(iris): one timetable toolbar, and move appearance into settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The public timetable's control bar was a showcase of unrelated styles: three button groups, a bespoke week pill, a print button in two different sizes, and a view toggle that only existed in the URL. It is now one bar built from a single segmented-control primitive (`ToggleGroup`), so every control shares one box and reads as the same object. - The filter, week and view controls are segments of one control language. `ToggleGroup` is a Base UI toggle group, so a single-choice group is a real radio group for keyboard and screen-reader users instead of buttons that merely look selected. - The week selector keeps a colour cue for A/B — as a tint matching the grid's week badges, not the saturated fill it had. - On a phone the bar is two rows: the entry type as icons with its picker, then week, timetable and print. Nothing scrolls sideways. - The loading placeholder is shaped like the timetable it stands in for; the old `h-8 w-64` strip read as a second, empty toolbar. Its borders use `border-border` — a bare `border` resolves to `currentColor`, which drew a white outline around the whole card. The view choice moved to a new Appearance settings pane, with the theme, and became a real preference: a module-level store so flipping it re-renders the timetable behind the dialog with no reload, a cookie so it applies before the first paint (and to a signed-out visitor), and the stored preference so it follows the account. It is no longer a `?view=` search param. Also: the navbar's language selector is gone (language lives in General), the API-key list is a list rather than a seven-column table that could not fit, and `` carries `suppressHydrationWarning` — next-themes sets `class` and `color-scheme` on it before hydration, which React warned about on every load. --- apps/iris/public/locales/en/translation.json | 12 +- apps/iris/public/locales/hu/translation.json | 12 +- .../src/components/api-keys/api-keys-card.tsx | 2 +- apps/iris/src/components/navbar.tsx | 3 - .../src/components/settings-dialog-store.ts | 1 + apps/iris/src/components/settings-dialog.tsx | 22 +- .../components/settings/appearance-pane.tsx | 142 ++++++++++ .../src/components/settings/general-pane.tsx | 43 +-- .../src/components/timetable/filter-bar.tsx | 259 ++++++++---------- apps/iris/src/components/timetable/index.tsx | 50 ++-- .../components/timetable/week-selector.tsx | 108 ++++---- .../src/components/util/language-selector.tsx | 71 ----- apps/iris/src/hooks/use-timetable-view.ts | 92 +++++++ apps/iris/src/routes/__root.tsx | 6 +- apps/iris/src/routes/_public/index.tsx | 1 - packages/ui/src/components/toggle-group.tsx | 72 +++++ 16 files changed, 554 insertions(+), 342 deletions(-) create mode 100644 apps/iris/src/components/settings/appearance-pane.tsx delete mode 100644 apps/iris/src/components/util/language-selector.tsx create mode 100644 apps/iris/src/hooks/use-timetable-view.ts create mode 100644 packages/ui/src/components/toggle-group.tsx diff --git a/apps/iris/public/locales/en/translation.json b/apps/iris/public/locales/en/translation.json index 54bac9c4..74ba1770 100644 --- a/apps/iris/public/locales/en/translation.json +++ b/apps/iris/public/locales/en/translation.json @@ -274,7 +274,10 @@ "cleanupOrphanedCohortsError": "Failed to clean up orphaned timetable data", "weekA": "Week A", "weekB": "Week B", - "weekAll": "All weeks" + "weekAll": "All weeks", + "filterLabel": "Filter by", + "viewBy": "View as", + "weekFilterLabel": "Week" }, "navigation": { "title": "Navigation", @@ -688,7 +691,7 @@ "themeLight": "Light", "themeDark": "Dark", "themeSystem": "System", - "timetableView": "Default timetable view", + "timetableView": "Timetable view", "cohort": "Cohort", "cohortDescription": "Choose the class or group associated with your account", "myGroups": "My groups", @@ -705,7 +708,10 @@ "saveSuccess": "Preferences saved", "saveError": "Failed to save preferences", "loadError": "Failed to load preferences", - "languageChangeError": "Could not change the language" + "languageChangeError": "Could not change the language", + "appearance": "Appearance", + "appearanceDescription": "How the app and the timetable look.", + "timetableViewDescription": "Grid is the timetable you see first; card is the printable paper layout." }, "unsubscribe": { "title": "Notification Preferences", diff --git a/apps/iris/public/locales/hu/translation.json b/apps/iris/public/locales/hu/translation.json index fdc7765a..c66b2dc9 100644 --- a/apps/iris/public/locales/hu/translation.json +++ b/apps/iris/public/locales/hu/translation.json @@ -256,7 +256,10 @@ "cleanupOrphanedCohortsError": "Nem sikerült az árva órarendi adatok tisztítása", "weekA": "A hét", "weekB": "B hét", - "weekAll": "Mindkettő" + "weekAll": "Mindkettő", + "filterLabel": "Szűrés", + "viewBy": "Nézet", + "weekFilterLabel": "Hét" }, "common": { "cancel": "Mégsem", @@ -688,7 +691,7 @@ "themeLight": "Világos", "themeDark": "Sötét", "themeSystem": "Rendszer szerint", - "timetableView": "Alapértelmezett órarend nézet", + "timetableView": "Órarend nézet", "cohort": "Osztály", "cohortDescription": "Válaszd ki a fiókodhoz tartozó osztályt vagy csoportot", "myGroups": "Csoportjaim", @@ -705,7 +708,10 @@ "saveSuccess": "Beállítások mentve", "saveError": "Nem sikerült menteni a beállításokat", "loadError": "Nem sikerült betölteni a beállításokat", - "languageChangeError": "Nem sikerült megváltoztatni a nyelvet" + "languageChangeError": "Nem sikerült megváltoztatni a nyelvet", + "appearance": "Megjelenés", + "appearanceDescription": "Az alkalmazás és az órarend megjelenése.", + "timetableViewDescription": "A rács a megszokott órarend; a kártya a nyomtatható papír nézet." }, "unsubscribe": { "title": "Értesítési beállítások", diff --git a/apps/iris/src/components/api-keys/api-keys-card.tsx b/apps/iris/src/components/api-keys/api-keys-card.tsx index fb5c4e7a..b7211029 100644 --- a/apps/iris/src/components/api-keys/api-keys-card.tsx +++ b/apps/iris/src/components/api-keys/api-keys-card.tsx @@ -74,7 +74,7 @@ function ApiKeyRow({ const { t, i18n } = useTranslation(); return ( -
  • +
  • diff --git a/apps/iris/src/components/navbar.tsx b/apps/iris/src/components/navbar.tsx index abc87700..228799f6 100644 --- a/apps/iris/src/components/navbar.tsx +++ b/apps/iris/src/components/navbar.tsx @@ -37,7 +37,6 @@ import { useTranslation } from 'react-i18next'; import { Lazy } from '@/components/lazy'; import { NotificationBell } from '@/components/notification-bell'; import { openSettings } from '@/components/settings-dialog-store'; -import { LanguageSelector } from '@/components/util/language-selector'; import { ADMIN_UI_PERMISSIONS, useHasPermission, @@ -156,8 +155,6 @@ export function Navbar({ {data && } {data && } - - {(() => { if (isPending) { return ( diff --git a/apps/iris/src/components/settings-dialog-store.ts b/apps/iris/src/components/settings-dialog-store.ts index fb5c3c5e..5954cbce 100644 --- a/apps/iris/src/components/settings-dialog-store.ts +++ b/apps/iris/src/components/settings-dialog-store.ts @@ -7,6 +7,7 @@ import { useSyncExternalStore } from 'react'; */ export const settingsSections = [ 'general', + 'appearance', 'groups', 'notifications', 'apiKeys', diff --git a/apps/iris/src/components/settings-dialog.tsx b/apps/iris/src/components/settings-dialog.tsx index 78a3d843..c6821610 100644 --- a/apps/iris/src/components/settings-dialog.tsx +++ b/apps/iris/src/components/settings-dialog.tsx @@ -13,12 +13,14 @@ import { ChevronLeft, KeyRound, type LucideIcon, + Palette, SlidersHorizontal, Users, } from 'lucide-react'; -import { type ComponentType, useEffect, useState } from 'react'; +import { type ComponentType, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ApiKeysPane } from '@/components/settings/api-keys-pane'; +import { AppearancePane } from '@/components/settings/appearance-pane'; import { GeneralPane } from '@/components/settings/general-pane'; import { GroupsPane } from '@/components/settings/groups-pane'; import { NotificationsPane } from '@/components/settings/notifications-pane'; @@ -39,6 +41,11 @@ const NAV: { labelKey: 'preferences.general', section: 'general', }, + { + icon: Palette, + labelKey: 'preferences.appearance', + section: 'appearance', + }, { icon: Users, labelKey: 'preferences.myGroups', section: 'groups' }, { icon: Bell, @@ -56,6 +63,7 @@ type PaneProps = { const PANES: Record> = { apiKeys: ApiKeysPane, + appearance: AppearancePane, general: GeneralPane, groups: GroupsPane, notifications: NotificationsPane, @@ -77,6 +85,11 @@ export function SettingsDialog() { } }, [open]); + // Focus lands on the dialog itself, not the first nav button: Base UI focuses + // the first focusable child by default, which drew a focus ring around + // "General" every time the dialog opened. + const contentRef = useRef(null); + const activeNav = NAV.find((item) => item.section === section) ?? { icon: SlidersHorizontal, labelKey: 'preferences.general', @@ -95,7 +108,12 @@ export function SettingsDialog() { return ( !next && closeSettings()} open={open}> - + {t('preferences.title')} {t('preferences.description')} diff --git a/apps/iris/src/components/settings/appearance-pane.tsx b/apps/iris/src/components/settings/appearance-pane.tsx new file mode 100644 index 00000000..a423ae4a --- /dev/null +++ b/apps/iris/src/components/settings/appearance-pane.tsx @@ -0,0 +1,142 @@ +import { Button } from '@filcdev/ui/components/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@filcdev/ui/components/card'; +import { + Select, + SelectTrigger, + SelectValue, +} from '@filcdev/ui/components/select'; +import { Skeleton } from '@filcdev/ui/components/skeleton'; +import { Spinner } from '@filcdev/ui/components/spinner'; +import { + ToggleGroup, + ToggleGroupItem, +} from '@filcdev/ui/components/toggle-group'; +import { LayoutGrid, Table2 } from 'lucide-react'; +import { useTheme } from 'next-themes'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + useNotificationSettings, + useUpdateNotificationSettings, +} from '@/hooks/notifications'; +import { + type TimetableView, + useTimetableView, +} from '@/hooks/use-timetable-view'; + +/** + * How the app and the timetable look: theme and the timetable view. + * + * The view is a shared store, so flipping it here re-renders the timetable + * behind the dialog immediately. The theme applies as soon as it is picked + * (next-themes); both are written to the stored preferences on save. + */ +export function AppearancePane({ + active, + onClose, +}: { + active: boolean; + onClose: () => void; +}) { + const { t } = useTranslation(); + const { setTheme: applyTheme } = useTheme(); + const settingsQuery = useNotificationSettings(active); + const { setView, view } = useTimetableView(settingsQuery.data?.timetableView); + const [theme, setTheme] = useState('system'); + + useEffect(() => { + if (settingsQuery.data) { + setTheme(settingsQuery.data.theme); + } + }, [settingsQuery.data]); + + const saveSettings = useUpdateNotificationSettings({ + onSaved: () => { + applyTheme(theme); + onClose(); + }, + }); + + if (settingsQuery.isLoading) { + return ; + } + + return ( +
    + + + {t('preferences.appearance')} + + {t('preferences.appearanceDescription')} + + + +
    + {t('preferences.theme')} + +
    + +
    +
    + {t('preferences.timetableView')} + { + const next = value[0] as TimetableView | undefined; + if (next) { + setView(next); + } + }} + value={[view]} + > + + + {t('timetable.viewGrid')} + + + + {t('timetable.viewCard')} + + +
    +

    + {t('preferences.timetableViewDescription')} +

    +
    +
    +
    + + +
    + ); +} diff --git a/apps/iris/src/components/settings/general-pane.tsx b/apps/iris/src/components/settings/general-pane.tsx index af8f9db0..3fb7e970 100644 --- a/apps/iris/src/components/settings/general-pane.tsx +++ b/apps/iris/src/components/settings/general-pane.tsx @@ -15,7 +15,6 @@ import { import { Skeleton } from '@filcdev/ui/components/skeleton'; import { Spinner } from '@filcdev/ui/components/spinner'; import { type UseQueryResult, useQuery } from '@tanstack/react-query'; -import { useTheme } from 'next-themes'; import { useEffect, useState } from 'react'; import { useCookies } from 'react-cookie'; import { useTranslation } from 'react-i18next'; @@ -69,9 +68,7 @@ function GeneralSettingsCard({ onGroupDisplayChange, onLanguageChange, onSelectedCohortIdChange, - onThemeChange, selectedCohortId, - theme, timetableGroupDisplay, }: { cohortQuery: UseQueryResult; @@ -79,9 +76,7 @@ function GeneralSettingsCard({ onGroupDisplayChange: (value: 'highlight' | 'hide') => void; onLanguageChange: (value: string | null) => void; onSelectedCohortIdChange: (value: string | null) => void; - onThemeChange: (value: string) => void; selectedCohortId: string | null; - theme: string; timetableGroupDisplay: 'highlight' | 'hide'; }) { const { t } = useTranslation(); @@ -115,23 +110,6 @@ function GeneralSettingsCard({
    -
    - {t('preferences.theme')} - -
    -
    {t('preferences.cohort')} @@ -193,11 +171,8 @@ export function GeneralPane({ }) { const { i18n, t } = useTranslation(); const [, setCookie] = useCookies(['filc.language']); - const { setTheme: applyTheme } = useTheme(); const { data: session } = useSession(); const [language, setLanguage] = useState('hu'); - const [theme, setTheme] = useState('system'); - const [timetableView, setTimetableView] = useState('class'); const [timetableGroupDisplay, setTimetableGroupDisplay] = useState< 'highlight' | 'hide' >('highlight'); @@ -217,8 +192,6 @@ export function GeneralPane({ return; } setLanguage(settings.language); - setTheme(settings.theme); - setTimetableView(settings.timetableView); setTimetableGroupDisplay( settings.timetableGroupDisplay === 'hide' ? 'hide' : 'highlight' ); @@ -232,10 +205,7 @@ export function GeneralPane({ }, [active, session?.user?.cohortId]); const saveSettings = useUpdateNotificationSettings({ - onSaved: () => { - applyTheme(theme); - onClose(); - }, + onSaved: onClose, updateCohort: async () => { const currentCohortId = session?.user?.cohortId ?? null; const newCohortId = @@ -291,23 +261,14 @@ export function GeneralPane({ onGroupDisplayChange={setTimetableGroupDisplay} onLanguageChange={handleLanguageChange} onSelectedCohortIdChange={setSelectedCohortId} - onThemeChange={setTheme} selectedCohortId={selectedCohortId} - theme={theme} timetableGroupDisplay={timetableGroupDisplay} /> - - - - - - - -
    + + {t(labelKey)} + + ))} + - {/* Class/teacher/room + timetable */} -
    -
    {renderSelect()}
    - -
    - -
    +
    + {renderSelect()}
    - {/* Desktop week selector + view selector */} -
    -
    - + + +
    +
    -
    - - - - - - - -
    +
    ); diff --git a/apps/iris/src/components/timetable/index.tsx b/apps/iris/src/components/timetable/index.tsx index 2c5db27c..9d1101ce 100644 --- a/apps/iris/src/components/timetable/index.tsx +++ b/apps/iris/src/components/timetable/index.tsx @@ -44,6 +44,7 @@ import { useTimetables, useTimetableUserSettings, } from '@/hooks/timetable-public'; +import { useTimetableView } from '@/hooks/use-timetable-view'; import { Route, type searchSchema } from '@/routes/_public/index'; import { orpc } from '@/utils/orpc'; @@ -485,7 +486,6 @@ export function TimetableView() { room: undefined, teacher: undefined, timetable: selectedTimetableId ?? undefined, - view: search.view, }; const paramKey = `${activeFilter}` as 'cohort' | 'teacher' | 'room'; @@ -495,13 +495,7 @@ export function TimetableView() { search: () => searchParams, }); } - }, [ - activeFilter, - activeSelectionId, - selectedTimetableId, - navigate, - search.view, - ]); + }, [activeFilter, activeSelectionId, selectedTimetableId, navigate]); const weekFilteredLessons = useMemo( () => @@ -561,22 +555,11 @@ export function TimetableView() { }; // Secondary (paper-like) view: the class code shown in the corner cell. - const view = search.view ?? 'grid'; + // The view itself is a preference (see the Appearance settings pane), kept in + // a cookie so it applies before the first paint. + const { view } = useTimetableView(settingsQuery.data?.timetableView); const cardHeader = buildCardHeader(getSelectionLabel()); - const handleViewChange = (nextView: 'grid' | 'card') => { - navigate({ - replace: true, - search: () => ({ - cohort: search.cohort, - room: search.room, - teacher: search.teacher, - timetable: search.timetable, - view: nextView, - }), - }); - }; - const handleGeneratePdf = async (blackAndWhite: boolean): Promise => { const timetableName = timetablesQuery.data?.find((entry) => entry.id === selectedTimetableId) @@ -682,7 +665,6 @@ export function TimetableView() { setSelections((s) => ({ ...s, teacher: id })) } onSelectTimetable={setSelectedTimetableId} - onViewChange={handleViewChange} onWeekFilterChange={setWeekFilter} selectedByClass={selections.class} selectedByRoom={selections.classroom} @@ -691,7 +673,6 @@ export function TimetableView() { selectorLoading={selectorLoading} teachers={teachersQuery.data} timetables={timetablesQuery.data ? visibleTimetables : undefined} - view={view} weekFilter={weekFilter} /> @@ -714,9 +695,24 @@ export function TimetableView() { title={t('timetable.noValidTimetableTitle')} /> ) : isLoading ? ( -
    - - + // A placeholder shaped like the timetable it stands in for. The old + // `h-8 w-64` strip read as a second, empty filter bar sitting under + // the real one. Colours match the real grid (`border-border bg-card`) + // — a bare `border` resolves to `currentColor`, which drew a white + // outline around the whole card. +
    +
    + +
    +
    + {[0, 1, 2, 3, 4].map((day) => ( +
    + + + +
    + ))} +
    ) : ( (() => { diff --git a/apps/iris/src/components/timetable/week-selector.tsx b/apps/iris/src/components/timetable/week-selector.tsx index bc299d89..99891912 100644 --- a/apps/iris/src/components/timetable/week-selector.tsx +++ b/apps/iris/src/components/timetable/week-selector.tsx @@ -1,5 +1,8 @@ -import { Button } from '@filcdev/ui/components/button'; -import { cn } from '@filcdev/ui/lib/utils'; +import { + ToggleGroup, + ToggleGroupItem, +} from '@filcdev/ui/components/toggle-group'; +import { Layers } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import type { WeekFilter } from './helpers'; @@ -9,6 +12,17 @@ type WeekSelectorProps = { disabled?: boolean; }; +/** + * Which week of the cycle to show. + * + * The same segmented track as the rest of the toolbar, with the A/B colours + * kept as the active tone so the control matches the week badges in the grid. + * On a phone it collapses to the A/B letters and an icon, which is what lets it + * share a row with the timetable selector and the print button. + * + * The narrow/wide switch is CSS (`sm:`), not a media query in JS: the server + * and the browser then render the same markup. + */ export function WeekSelector({ value, onChange, @@ -16,55 +30,53 @@ export function WeekSelector({ }: WeekSelectorProps) { const { t } = useTranslation(); - const items: Array<{ - value: WeekFilter; + const items: { label: string; - }> = [ - { - label: t('timetable.weekA'), - value: 'A', - }, - { - label: t('timetable.weekB'), - value: 'B', - }, - { - label: t('timetable.weekAll'), - value: 'all', - }, + tone: 'weekA' | 'weekB' | 'default'; + value: WeekFilter; + }[] = [ + { label: t('timetable.weekA'), tone: 'weekA', value: 'A' }, + { label: t('timetable.weekB'), tone: 'weekB', value: 'B' }, + { label: t('timetable.weekAll'), tone: 'default', value: 'all' }, ]; return ( -
    - {items.map((item) => { - const active = value === item.value; - - return ( - - ); - })} -
    + { + const selected = next[0]; + if (selected) { + onChange(selected as WeekFilter); + } + }} + value={[value]} + > + {items.map((item) => ( + + {/* The letter is the mark for A/B in both layouts — the two icons + would otherwise be identical. */} + {item.value === 'all' ? ( + <> + + {item.label} + + ) : ( + <> + {item.value} + {item.label} + + )} + + ))} + ); } diff --git a/apps/iris/src/components/util/language-selector.tsx b/apps/iris/src/components/util/language-selector.tsx deleted file mode 100644 index d606b4eb..00000000 --- a/apps/iris/src/components/util/language-selector.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { Button } from '@filcdev/ui/components/button'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '@filcdev/ui/components/dropdown-menu'; -import { ChevronDown, Globe } from 'lucide-react'; -import { useCookies } from 'react-cookie'; -import { useTranslation } from 'react-i18next'; - -type LangCode = 'EN' | 'HU'; - -const LANGUAGES: ReadonlyArray<{ code: LangCode; name: string }> = [ - { code: 'EN', name: 'English' }, - { code: 'HU', name: 'Magyar' }, -] as const; - -export function LanguageSelector() { - const { i18n, t } = useTranslation(); - const [, setCookie] = useCookies(['filc.language']); - - const current: LangCode = i18n.language as LangCode; - - const handleChange = async (code: LangCode) => { - const lng = code.toLowerCase(); - await i18n.changeLanguage(lng); - setCookie('filc.language', lng, { sameSite: 'lax' }); - if (typeof document !== 'undefined') { - document.documentElement.lang = lng; - } - }; - - return ( - - - - {current?.toUpperCase()} - - - } - /> - - - {t('selectLanguage')} - - - {LANGUAGES.map((lang) => ( - handleChange(lang.code)} - > - {lang.name} - - ))} - - - ); -} - -export default LanguageSelector; diff --git a/apps/iris/src/hooks/use-timetable-view.ts b/apps/iris/src/hooks/use-timetable-view.ts new file mode 100644 index 00000000..72cec63b --- /dev/null +++ b/apps/iris/src/hooks/use-timetable-view.ts @@ -0,0 +1,92 @@ +import { useEffect, useSyncExternalStore } from 'react'; + +const VIEW_COOKIE_NAME = 'filc.timetable-view'; +const VIEW_COOKIE_MAX_AGE = 365 * 24 * 60 * 60; // 1 year + +export type TimetableView = 'grid' | 'card'; + +const isTimetableView = (value: unknown): value is TimetableView => + value === 'grid' || value === 'card'; + +const readCookie = (): TimetableView | null => { + if (typeof document === 'undefined') { + return null; + } + const match = document.cookie + .split(';') + .map((part) => part.trim()) + .find((part) => part.startsWith(`${VIEW_COOKIE_NAME}=`)); + const value = match?.slice(VIEW_COOKIE_NAME.length + 1); + return isTimetableView(value) ? value : null; +}; + +let view: TimetableView = 'grid'; +/** Set once either the cookie or the stored preference has been consulted. */ +let seeded = false; + +const listeners = new Set<() => void>(); + +// Module-level so the reference is stable for `useSyncExternalStore`. +const subscribe = (listener: () => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +}; + +const getSnapshot = () => view; + +const apply = (next: TimetableView, persist: boolean) => { + if (next === view) { + return; + } + view = next; + if (persist) { + // biome-ignore lint/suspicious/noDocumentCookie: read on the next visit + document.cookie = `${VIEW_COOKIE_NAME}=${next}; path=/; max-age=${VIEW_COOKIE_MAX_AGE}; samesite=lax`; + } + for (const listener of listeners) { + listener(); + } +}; + +/** + * The timetable view preference: grid or the paper-like card. + * + * One module-level store, so the Appearance pane and the timetable page are the + * same state: changing the control re-renders the timetable immediately, with + * no reload. The choice persists in a cookie — available before the first paint + * on the next visit, and it also applies to a signed-out visitor — and is + * written to the stored preferences on save so it follows the account. + * + * Seeding happens in an effect, not during render: reading a cookie during + * render would make the server and client disagree on the first HTML. + */ +export function useTimetableView(stored?: string) { + const current = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + + useEffect(() => { + if (seeded) { + return; + } + const cookie = readCookie(); + if (cookie) { + seeded = true; + apply(cookie, false); + return; + } + // A device with no cookie yet adopts the account's stored value. + if (isTimetableView(stored)) { + seeded = true; + apply(stored, false); + } + }, [stored]); + + return { + setView: (next: TimetableView) => { + seeded = true; + apply(next, true); + }, + view: current, + }; +} diff --git a/apps/iris/src/routes/__root.tsx b/apps/iris/src/routes/__root.tsx index 71171e53..15458678 100644 --- a/apps/iris/src/routes/__root.tsx +++ b/apps/iris/src/routes/__root.tsx @@ -88,7 +88,11 @@ function RootComponent() { const [i18n] = useState(() => createI18n(language)); return ( - + // `suppressHydrationWarning` is required by next-themes: its inline script + // sets `class` and `color-scheme` on before React hydrates, so the + // server HTML and the first client render legitimately differ. React would + // otherwise warn on every load and refuse to patch the attributes. + diff --git a/apps/iris/src/routes/_public/index.tsx b/apps/iris/src/routes/_public/index.tsx index 3a58bcfc..77a70cf6 100644 --- a/apps/iris/src/routes/_public/index.tsx +++ b/apps/iris/src/routes/_public/index.tsx @@ -11,7 +11,6 @@ export const searchSchema = z.object({ room: z.string().optional(), teacher: z.string().optional(), timetable: z.string().optional(), - view: z.enum(['grid', 'card']).optional(), }); export const Route = createFileRoute('/_public/')({ diff --git a/packages/ui/src/components/toggle-group.tsx b/packages/ui/src/components/toggle-group.tsx new file mode 100644 index 00000000..93e07a01 --- /dev/null +++ b/packages/ui/src/components/toggle-group.tsx @@ -0,0 +1,72 @@ +import { Toggle as TogglePrimitive } from '@base-ui/react/toggle'; +import { ToggleGroup as ToggleGroupPrimitive } from '@base-ui/react/toggle-group'; +import { cva, type VariantProps } from 'class-variance-authority'; +import type { ComponentProps } from 'react'; +import { cn } from '../lib/utils'; + +/** + * A segmented control: one track, one active segment. Built on Base UI's + * toggle group so a single-selection group is a real radio group for keyboard + * and screen-reader users (`aria-pressed`, arrow-key roving focus) instead of a + * row of buttons that merely look selected. + * + * `ToggleGroup` is the track; put `ToggleGroupItem`s inside it. + */ +function ToggleGroup({ + className, + orientation = 'horizontal', + ...props +}: ComponentProps) { + return ( + + ); +} + +const toggleGroupItemVariants = cva( + "inline-flex h-8 min-w-0 flex-1 cursor-pointer select-none items-center justify-center gap-1.5 truncate rounded-4xl px-3 font-medium text-sm outline-hidden transition-colors sm:flex-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", + { + defaultVariants: { + tone: 'default', + }, + variants: { + tone: { + default: + 'text-muted-foreground hover:bg-muted hover:text-foreground data-[pressed]:bg-background data-[pressed]:text-foreground data-[pressed]:shadow-sm', + // A/B keep a colour cue so the two week types stay recognisable, but as + // a tinted segment rather than a saturated fill — the control sits in a + // toolbar, not in the timetable it labels. The tints match the week + // badges the grid uses. + weekA: + 'text-muted-foreground hover:bg-muted hover:text-foreground data-[pressed]:bg-blue-500/15 data-[pressed]:text-blue-700 data-[pressed]:shadow-sm dark:data-[pressed]:text-blue-300', + weekB: + 'text-muted-foreground hover:bg-muted hover:text-foreground data-[pressed]:bg-violet-500/15 data-[pressed]:text-violet-700 data-[pressed]:shadow-sm dark:data-[pressed]:text-violet-300', + }, + }, + } +); + +function ToggleGroupItem({ + className, + tone, + ...props +}: ComponentProps & + VariantProps) { + return ( + + ); +} + +export { ToggleGroup, ToggleGroupItem, toggleGroupItemVariants }; From 723f280f5cbd89e854a6fb44083842e6d55e1e22 Mon Sep 17 00:00:00 2001 From: Tamas Vince Date: Thu, 8 Oct 2026 12:35:55 +0200 Subject: [PATCH 24/32] fix(iris): make the filter bar not shitty again --- .../src/components/timetable/filter-bar.tsx | 15 +- apps/iris/src/components/timetable/index.tsx | 668 ++++++++++++------ .../timetable/timetable-selector.tsx | 4 +- .../components/timetable/week-selector.tsx | 12 +- 4 files changed, 454 insertions(+), 245 deletions(-) diff --git a/apps/iris/src/components/timetable/filter-bar.tsx b/apps/iris/src/components/timetable/filter-bar.tsx index c7de7788..bf6d75ed 100644 --- a/apps/iris/src/components/timetable/filter-bar.tsx +++ b/apps/iris/src/components/timetable/filter-bar.tsx @@ -284,9 +284,10 @@ export function FilterBar({ }; return ( - // Two rows on a phone — what to show, then which week of which timetable — - // and one wrapping row from `sm` up. Every group is sized to its content, - // so nothing is stretched to fill a row it does not need. + // Two rows on a phone, one wrapping row from `sm` up. The control *labels* + // wait for `lg` (see the segments below and the week selector): a tablet + // cannot fit three labelled segments plus two pickers on one line, and an + // icon-only segment is still legible where a wrapped row is not.
    {/* Row 1: the entry type (icons only on a phone) and its picker. */}
    @@ -304,19 +305,19 @@ export function FilterBar({ {FILTERS.map(({ filter, icon: Icon, labelKey }) => ( - {t(labelKey)} + {t(labelKey)} ))} -
    +
    {renderSelect()}
    @@ -329,7 +330,7 @@ export function FilterBar({ value={weekFilter} /> -
    +
    ); -// Component -export function TimetableView() { - const search = Route.useSearch(); - const { i18n, t } = useTranslation(); - const { data: session, isPending } = useSession(); - const navigate = useNavigate({ from: Route.fullPath }); - const queryClient = useQueryClient(); +/** + * A placeholder shaped like the timetable it stands in for. The old + * `h-8 w-64` strip read as a second, empty toolbar sitting under the real one. + * Colours match the real grid (`border-border bg-card`) — a bare `border` + * resolves to `currentColor`, which drew a white outline around the card. + */ +function TimetableLoadingPlaceholder() { + return ( +
    +
    + +
    +
    + {[0, 1, 2, 3, 4].map((day) => ( +
    + + + +
    + ))} +
    +
    + ); +} - const isAuthenticated = !isPending && !!session; +/** + * The selection lifecycle: seed the filter and entry from the URL or sensible + * defaults once every list has loaded, keep the selection valid when the filter + * changes, clear the class when the timetable changes (cohorts are scoped to a + * timetable), and mirror the result back into the URL. + * + * A hook rather than four effects in the page component: the ordering between + * them is the whole point, and it is easier to follow in one place. + */ +const useTimetableSelection = ({ + activeFilter, + activeSelectionId, + classrooms, + cohorts, + initialized, + isAuthenticated, + isPending, + myTeacher, + myTeacherPending, + navigate, + search, + selectedTimetableId, + selections, + sessionCohortId, + setActiveFilter, + setInitialized, + setSelections, + teachers, +}: { + activeFilter: FilterType; + activeSelectionId: string | null; + classrooms?: ClassroomItem[]; + cohorts?: CohortItem[]; + initialized: boolean; + isAuthenticated: boolean; + isPending: boolean; + myTeacher: TeacherItem | null; + myTeacherPending: boolean; + navigate: (options: { + replace: boolean; + search: () => z.Infer; + }) => void; + search: TimetableSearchParams; + selectedTimetableId: string | null; + selections: SelectionsType; + sessionCohortId: string | null; + setActiveFilter: (filter: FilterType) => void; + setInitialized: (initialized: boolean) => void; + setSelections: Dispatch>; + teachers?: TeacherItem[]; +}) => { + // Seed from the URL, or the user's own class/teacher profile. + useEffect(() => { + const ready = isSelectionSeedReady({ + classrooms, + cohorts, + initialized, + isAuthenticated, + isPending, + myTeacherPending, + teachers, + }); + + if (!ready) { + return; + } + + applyInitialSelection({ + fallbackCohortId: sessionCohortId, + lists: { + class: cohorts ?? [], + classroom: classrooms ?? [], + teacher: teachers ?? [], + }, + myTeacher, + search, + setActiveFilter, + setSelections, + }); + + setInitialized(true); + }, [ + classrooms, + cohorts, + teachers, + sessionCohortId, + isPending, + isAuthenticated, + myTeacher, + myTeacherPending, + initialized, + search, + setActiveFilter, + setInitialized, + setSelections, + ]); + + // Keep a valid entry selected when the filter changes. + useEffect(() => { + if (!initialized) { + return; + } + + const defaultSelection = resolveDefaultSelection({ + filter: activeFilter, + lists: { + class: cohorts ?? [], + classroom: classrooms ?? [], + teacher: teachers ?? [], + }, + selections, + }); + + if (defaultSelection) { + applySelection( + setSelections, + defaultSelection.filter, + defaultSelection.id + ); + } + }, [ + initialized, + activeFilter, + classrooms, + cohorts, + teachers, + selections, + setSelections, + ]); + + // Reset the class selection when the timetable changes. + const [prevTimetableId, setPrevTimetableId] = useState(null); + useEffect(() => { + if (!selectedTimetableId || selectedTimetableId === prevTimetableId) { + return; + } + if (prevTimetableId !== null) { + setSelections((current) => ({ ...current, class: null })); + setInitialized(false); + } + setPrevTimetableId(selectedTimetableId); + }, [selectedTimetableId, prevTimetableId, setInitialized, setSelections]); + + // Mirror the selection into the URL so a link is shareable. + useEffect(() => { + const params = buildSelectionSearch({ + activeFilter, + activeSelectionId, + selectedTimetableId, + }); + if (params) { + navigate({ replace: true, search: () => params }); + } + }, [activeFilter, activeSelectionId, selectedTimetableId, navigate]); +}; + +/** + * Whether every list is in place to seed the selection: all three loaded, auth + * resolved, and the teacher profile settled (it loads after auth, and a teacher + * must not be defaulted to their class while it is still pending). + */ +const isSelectionSeedReady = ({ + classrooms, + cohorts, + initialized, + isAuthenticated, + isPending, + myTeacherPending, + teachers, +}: { + classrooms?: ClassroomItem[]; + cohorts?: CohortItem[]; + initialized: boolean; + isAuthenticated: boolean; + isPending: boolean; + myTeacherPending: boolean; + teachers?: TeacherItem[]; +}): boolean => + Boolean(cohorts && teachers && classrooms) && + !initialized && + !isPending && + !(isAuthenticated && myTeacherPending); + +/** Whether the picker for the active filter is still loading. */ +const isSelectorLoading = ( + activeFilter: FilterType, + loading: { classrooms: boolean; cohorts: boolean; teachers: boolean } +): boolean => { + switch (activeFilter) { + case 'class': + return loading.cohorts; + case 'teacher': + return loading.teachers; + case 'classroom': + return loading.classrooms; + default: + return false; + } +}; + +/** + * The search params for the current selection, or `null` when there is nothing + * selected to put in the URL. + */ +const buildSelectionSearch = ({ + activeFilter, + activeSelectionId, + selectedTimetableId, +}: { + activeFilter: FilterType; + activeSelectionId: string | null; + selectedTimetableId: string | null; +}): z.Infer | null => { + if (!activeSelectionId) { + return null; + } - // Fetch user settings for class colors (authenticated users only) + const params: z.Infer = { + cohort: undefined, + room: undefined, + teacher: undefined, + timetable: selectedTimetableId ?? undefined, + }; + const paramKey = `${activeFilter}` as 'cohort' | 'teacher' | 'room'; + params[paramKey] = activeSelectionId; + return params; +}; + +/** + * The signed-in user's per-subject colours, and the writer for them. + * + * Anonymous visitors get an empty map (there is nothing to load and nothing to + * save); the mutation invalidates the shared settings query so the picker and + * the grid stay in step. + */ +const useTimetableClassColors = (isAuthenticated: boolean) => { + const queryClient = useQueryClient(); const settingsQuery = useTimetableUserSettings(isAuthenticated); const userColors = isAuthenticated ? (settingsQuery.data?.timetableClassColors ?? {}) : {}; - // Mutation to save class color const colorMutation = useMutation( orpc.notifications.updateSettings.mutationOptions({ onSuccess: () => { @@ -278,6 +528,92 @@ export function TimetableView() { [colorMutation, userColors] ); + return { handleColorChange, userColors }; +}; + +/** The filter a URL implies, when it names one of the three entry types. */ +const initialFilterFromSearch = (search: TimetableSearchParams): FilterType => { + if (search.cohort) { + return 'class'; + } + if (search.teacher) { + return 'teacher'; + } + if (search.room) { + return 'classroom'; + } + return 'class'; +}; + +/** + * Whether group highlighting applies: a signed-in student looking at their own + * class. Any other combination shows the plain timetable. + */ +const isShowingOwnClass = ({ + activeFilter, + isAuthenticated, + selectedClassId, + sessionCohortId, +}: { + activeFilter: FilterType; + isAuthenticated: boolean; + selectedClassId: string | null; + sessionCohortId: string | null; +}): boolean => + isAuthenticated && + activeFilter === 'class' && + selectedClassId !== null && + selectedClassId === sessionCohortId; + +/** The name of the entry the current filter points at, for the card header. */ +const resolveSelectionLabel = ( + activeFilter: FilterType, + { + classrooms, + cohorts, + selections, + teachers, + }: { + classrooms?: ClassroomItem[]; + cohorts?: CohortItem[]; + selections: SelectionsType; + teachers?: TeacherItem[]; + } +): string => { + switch (activeFilter) { + case 'class': + return ( + cohorts?.find((entry) => entry.id === selections.class)?.name ?? '' + ); + case 'teacher': { + const teacher = teachers?.find( + (entry) => entry.id === selections.teacher + ); + return teacher ? `${teacher.firstName} ${teacher.lastName}`.trim() : ''; + } + case 'classroom': + return ( + classrooms?.find((entry) => entry.id === selections.classroom)?.name ?? + '' + ); + default: + return ''; + } +}; + +// Component +export function TimetableView() { + const search = Route.useSearch(); + const { i18n, t } = useTranslation(); + const { data: session, isPending } = useSession(); + const navigate = useNavigate({ from: Route.fullPath }); + + const isAuthenticated = !isPending && !!session; + + const settingsQuery = useTimetableUserSettings(isAuthenticated); + const { handleColorChange, userColors } = + useTimetableClassColors(isAuthenticated); + // Timetable query (all timetables) const timetablesQuery = useTimetables(); @@ -334,18 +670,9 @@ export function TimetableView() { const myTeacher = myTeacherQuery.data ?? null; // State - const [activeFilter, setActiveFilter] = useState(() => { - if (search.cohort) { - return 'class'; - } - if (search.teacher) { - return 'teacher'; - } - if (search.room) { - return 'classroom'; - } - return 'class'; - }); + const [activeFilter, setActiveFilter] = useState(() => + initialFilterFromSearch(search) + ); const [selections, setSelections] = useState({ class: null, classroom: null, @@ -366,11 +693,12 @@ export function TimetableView() { ); // Group highlighting applies to a signed-in student viewing their own class. - const showGroupHandling = - isAuthenticated && - activeFilter === 'class' && - selections.class !== null && - selections.class === session?.user?.cohortId; + const showGroupHandling = isShowingOwnClass({ + activeFilter, + isAuthenticated, + selectedClassId: selections.class, + sessionCohortId: session?.user?.cohortId ?? null, + }); const { groupDisplay, selectedDivisionTags, selectedGroupIds } = useTimetableGroupDisplay( showGroupHandling ? selections.class : null, @@ -378,124 +706,32 @@ export function TimetableView() { settingsQuery.data?.timetableGroupDisplay ); - // Initialize from URL or defaults - useEffect(() => { - // The teacher profile loads after auth resolves; wait for it before - // deciding the default so a teacher isn't defaulted to their class. - const myTeacherLoaded = !(isAuthenticated && myTeacherQuery.isPending); - const allDataLoaded = - cohortsQuery.data && - teachersQuery.data && - classroomsQuery.data && - myTeacherLoaded; - - if (!allDataLoaded || initialized || isPending) { - return; - } - - const lists = { - class: cohortsQuery.data, - classroom: classroomsQuery.data, - teacher: teachersQuery.data, - }; - - applyInitialSelection({ - fallbackCohortId: session?.user?.cohortId ?? null, - lists, - myTeacher, - search: { - cohort: search.cohort, - room: search.room, - teacher: search.teacher, - }, - setActiveFilter, - setSelections, - }); - - setInitialized(true); - }, [ - cohortsQuery.data, - teachersQuery.data, - classroomsQuery.data, - session, - isPending, + // Selection plumbing: seed from the URL, follow filter changes, reset when + // the timetable changes, and mirror the selection back into the URL. + useTimetableSelection({ + activeFilter, + activeSelectionId, + classrooms: classroomsQuery.data, + cohorts: cohortsQuery.data, + initialized, isAuthenticated, + isPending, myTeacher, - myTeacherQuery.isPending, - initialized, - search.cohort, - search.teacher, - search.room, - ]); - - // Set default selection when filter changes - useEffect(() => { - if (!initialized) { - return; - } - - const defaultSelection = resolveDefaultSelection({ - filter: activeFilter, - lists: { - class: cohortsQuery.data ?? [], - classroom: classroomsQuery.data ?? [], - teacher: teachersQuery.data ?? [], - }, - selections: { - class: selections.class, - classroom: selections.classroom, - teacher: selections.teacher, - }, - }); - - if (defaultSelection) { - applySelection( - setSelections, - defaultSelection.filter, - defaultSelection.id - ); - } - }, [ - initialized, - activeFilter, - cohortsQuery.data, - teachersQuery.data, - classroomsQuery.data, - selections.class, - selections.classroom, - selections.teacher, - ]); - - // Reset class selection when timetable changes (cohorts are timetable-scoped) - const [prevTimetableId, setPrevTimetableId] = useState(null); - useEffect(() => { - if (selectedTimetableId && selectedTimetableId !== prevTimetableId) { - if (prevTimetableId !== null) { - setSelections((s) => ({ ...s, class: null })); - setInitialized(false); - } - setPrevTimetableId(selectedTimetableId); - } - }, [selectedTimetableId, prevTimetableId]); - - // Sync selection to URL - useEffect(() => { - if (activeSelectionId) { - const searchParams: z.Infer = { - cohort: undefined, - room: undefined, - teacher: undefined, - timetable: selectedTimetableId ?? undefined, - }; - - const paramKey = `${activeFilter}` as 'cohort' | 'teacher' | 'room'; - searchParams[paramKey] = activeSelectionId; - navigate({ - replace: true, - search: () => searchParams, - }); - } - }, [activeFilter, activeSelectionId, selectedTimetableId, navigate]); + myTeacherPending: myTeacherQuery.isPending, + navigate, + search: { + cohort: search.cohort, + room: search.room, + teacher: search.teacher, + }, + selectedTimetableId, + selections, + sessionCohortId: session?.user?.cohortId ?? null, + setActiveFilter, + setInitialized, + setSelections, + teachers: teachersQuery.data, + }); const weekFilteredLessons = useMemo( () => @@ -529,42 +765,29 @@ export function TimetableView() { const [printDialogOpen, setPrintDialogOpen] = useState(false); - const getSelectionLabel = (): string => { - switch (activeFilter) { - case 'class': - return ( - cohortsQuery.data?.find((c) => c.id === selections.class)?.name ?? '' - ); - case 'teacher': { - const teacher = teachersQuery.data?.find( - (entry) => entry.id === selections.teacher - ); - if (!teacher) { - return ''; - } - return `${teacher.firstName} ${teacher.lastName}`.trim(); - } - case 'classroom': - return ( - classroomsQuery.data?.find((c) => c.id === selections.classroom) - ?.name ?? '' - ); - default: - return ''; - } - }; - // Secondary (paper-like) view: the class code shown in the corner cell. // The view itself is a preference (see the Appearance settings pane), kept in // a cookie so it applies before the first paint. const { view } = useTimetableView(settingsQuery.data?.timetableView); - const cardHeader = buildCardHeader(getSelectionLabel()); + const cardHeader = buildCardHeader( + resolveSelectionLabel(activeFilter, { + classrooms: classroomsQuery.data, + cohorts: cohortsQuery.data, + selections, + teachers: teachersQuery.data, + }) + ); const handleGeneratePdf = async (blackAndWhite: boolean): Promise => { const timetableName = timetablesQuery.data?.find((entry) => entry.id === selectedTimetableId) ?.name ?? ''; - const label = getSelectionLabel(); + const label = resolveSelectionLabel(activeFilter, { + classrooms: classroomsQuery.data, + cohorts: cohortsQuery.data, + selections, + teachers: teachersQuery.data, + }); const generatedAt = new Date().toLocaleDateString(i18n.language, { day: '2-digit', month: '2-digit', @@ -593,20 +816,11 @@ export function TimetableView() { window.open(url, '_blank'); }; - const getSelectorLoading = () => { - switch (activeFilter) { - case 'class': - return cohortsQuery.isLoading; - case 'teacher': - return teachersQuery.isLoading; - case 'classroom': - return classroomsQuery.isLoading; - default: - return false; - } - }; - - const selectorLoading = getSelectorLoading(); + const selectorLoading = isSelectorLoading(activeFilter, { + classrooms: classroomsQuery.isLoading, + cohorts: cohortsQuery.isLoading, + teachers: teachersQuery.isLoading, + }); /** * No current timetable: the backend answers `latestValid` with NOT_FOUND when @@ -623,11 +837,12 @@ export function TimetableView() { const isLoading = selectorLoading || lessonsQuery.isLoading || !activeSelectionId; - const hasError = - cohortsQuery.error || - teachersQuery.error || - classroomsQuery.error || - lessonsQuery.error; + const hasError = Boolean( + cohortsQuery.error ?? + teachersQuery.error ?? + classroomsQuery.error ?? + lessonsQuery.error + ); const timetableContent = renderTimetableBody( view, @@ -649,6 +864,38 @@ export function TimetableView() { } ); + /** + * Which body to show. An early return per state keeps this out of the JSX as + * a chain of ternaries, and keeps the component's own branching to a minimum. + */ + const renderBody = () => { + if (hasNoValidTimetable) { + return ( + } + title={t('timetable.noValidTimetableTitle')} + /> + ); + } + + if (isLoading) { + return ; + } + + if (!hasError && weekFilteredLessons.length === 0) { + return ( + } + title={t('timetable.emptyWeekTitle')} + /> + ); + } + + return timetableContent; + }; + return (
    @@ -684,50 +931,11 @@ export function TimetableView() { {hasError && (
    - Failed to load timetable. + {t('timetable.loadError')}
    )} - {hasNoValidTimetable ? ( - } - title={t('timetable.noValidTimetableTitle')} - /> - ) : isLoading ? ( - // A placeholder shaped like the timetable it stands in for. The old - // `h-8 w-64` strip read as a second, empty filter bar sitting under - // the real one. Colours match the real grid (`border-border bg-card`) - // — a bare `border` resolves to `currentColor`, which drew a white - // outline around the whole card. -
    -
    - -
    -
    - {[0, 1, 2, 3, 4].map((day) => ( -
    - - - -
    - ))} -
    -
    - ) : ( - (() => { - if (!hasError && weekFilteredLessons.length === 0) { - return ( - } - title={t('timetable.emptyWeekTitle')} - /> - ); - } - return timetableContent; - })() - )} + {renderBody()}
    ); diff --git a/apps/iris/src/components/timetable/timetable-selector.tsx b/apps/iris/src/components/timetable/timetable-selector.tsx index 118e404e..d46e8527 100644 --- a/apps/iris/src/components/timetable/timetable-selector.tsx +++ b/apps/iris/src/components/timetable/timetable-selector.tsx @@ -51,7 +51,7 @@ export function TimetableSelector({ const { t } = useTranslation(); if (loading || !timetables) { - return ; + return ; } const selected = timetables.find((item) => item.id === selectedId); @@ -62,7 +62,7 @@ export function TimetableSelector({ diff --git a/apps/iris/src/components/timetable/week-selector.tsx b/apps/iris/src/components/timetable/week-selector.tsx index 99891912..e9755404 100644 --- a/apps/iris/src/components/timetable/week-selector.tsx +++ b/apps/iris/src/components/timetable/week-selector.tsx @@ -20,7 +20,7 @@ type WeekSelectorProps = { * On a phone it collapses to the A/B letters and an icon, which is what lets it * share a row with the timetable selector and the print button. * - * The narrow/wide switch is CSS (`sm:`), not a media query in JS: the server + * The narrow/wide switch is CSS (`lg:`), not a media query in JS: the server * and the browser then render the same markup. */ export function WeekSelector({ @@ -55,7 +55,7 @@ export function WeekSelector({ {items.map((item) => ( - - {item.label} + + {item.label} ) : ( <> - {item.value} - {item.label} + {item.value} + {item.label} )} From a95914880e2ee45ce75e33f2c8adf3d868bf2d83 Mon Sep 17 00:00:00 2001 From: Tamas Vince Date: Thu, 8 Oct 2026 12:49:48 +0200 Subject: [PATCH 25/32] chore: update tailwind syntax and agent skills --- .../skills/chronos-route-procedure/SKILL.md | 17 ++-- .../skills/chronos-schema-procedure/SKILL.md | 12 +-- .agents/skills/iris-data-flow/SKILL.md | 4 +- apps/iris/src/components/subs-view.tsx | 2 +- .../src/components/system-message-banner.tsx | 2 +- .../src/components/timetable/filter-bar.tsx | 2 +- .../src/routes/_private/admin/bug-reports.tsx | 2 +- packages/ui/src/components/table.tsx | 4 +- packages/ui/src/components/toggle-group.tsx | 6 +- packages/ui/src/styles/globals.css | 86 +++++++++---------- 10 files changed, 69 insertions(+), 68 deletions(-) diff --git a/.agents/skills/chronos-route-procedure/SKILL.md b/.agents/skills/chronos-route-procedure/SKILL.md index 3deb7acd..54af5db4 100644 --- a/.agents/skills/chronos-route-procedure/SKILL.md +++ b/.agents/skills/chronos-route-procedure/SKILL.md @@ -1,13 +1,14 @@ --- name: chronos-route-procedure -description: Procedures for adding or changing Chronos API routes, Hono handlers, validators, OpenAPI metadata, authentication or authorization middleware, or feature router registration in apps/chronos/src/routes. Use when creating endpoints, wiring zod validators, describing responses with hono-openapi, or registering routers. +description: Procedures for adding or changing Chronos API endpoints — oRPC contract procedures, module handlers, router registration, error responses, authentication or authorization middleware, or OpenAPI metadata. Use when creating endpoints in the oRPC stack, wiring validation schemas, or exposing a new feature on the API. --- # Chronos Route Procedure -- Keep route work inside the feature folder under `apps/chronos/src/routes`, following the `_factory.ts`, `_router.ts`, and handler-file pattern used in `apps/chronos/src/routes/doorlock`. -- Match handler structure from files like `apps/chronos/src/routes/ping/index.ts`: `describeRoute(...)` first, then shared middleware, then validators, then the final handler. -- Request/response zod wire schemas live in `packages/api/src/domains/` (`@filcdev/api/domains/`) so client apps share them — import from there instead of re-declaring. Schemas derived from Drizzle tables stay local to Chronos. Purely local helper schemas stay close to the route that uses them. -- Use `requireAuthentication` and `requireAuthorization(permissions.)` middleware instead of inline permission checks; constants come from `@filcdev/api/permissions`. Never inline `'resource:action'` string literals. -- Return the established success envelope via the `ok()` helpers in `apps/chronos/src/utils/http.ts`; use `HTTPException` plus `StatusCodes` for error paths. -- Register every endpoint in the feature router (`_router.ts`) so the handler is reachable; do not wire ad hoc routes across the app. -- Ship complete OpenAPI metadata with the route change: tags via `filcExt(...)`, descriptions, and response documentation via `resolver(...)`. +- Endpoints are oRPC procedures, not Hono routes. The contract is the single source of truth: declare one procedure per endpoint in `packages/api/src/contract/.ts` as `oc.route(filcRoute({...})).input(...).output(...)`. `filcRoute` carries `method`, `path`, `operationId`, `tags`, `successStatus` and the `x-filc_*` values mergen's Kotlin client reads — copy the pattern from `packages/api/src/contract/users.ts`. +- `.output(...)` is mandatory on every procedure. `.input(...)` is ONE flat object — path params plus query (GET) or the JSON body (POST/PUT/PATCH) — with the same field names as the wire. +- Implement handlers in the feature folder under `apps/chronos/src/modules/`: handler files, a `_router.ts` exporting a plain object literal mirroring the contract nesting, `schema.ts`, and a `_module.ts` manifest when the feature owns jobs or notifications. Handler shape: `base...handler(async ({ input, context }) => payload)` where `base` is `implement(appContract)` from `apps/chronos/src/orpc.ts` — see `apps/chronos/src/modules/ping/index.ts`. +- Register every handler in the feature's `_router.ts`; the full contract is assembled once via `base.router({...})` in `apps/chronos/src/router.ts`. A missing, extra or mistyped procedure fails `tsc` rather than 404ing at runtime. +- Success is `return payload` — there is no `{ success, data }` envelope. Errors are thrown `ORPCError`s: the helpers in `apps/chronos/src/utils/http.ts` (`notFound`, `conflict`, `badRequest`, `forbidden`, `unauthorized`, `badGateway`, `serviceUnavailable`) or the `errors.(...)` factories from the shared error map in `packages/api/src/errors.ts`. Never use `HTTPException`, `StatusCodes`, or an `ok()` wrapper — they no longer exist. +- Use `requireAuthentication` and `requireAuthorization(permissions.)` from `apps/chronos/src/middleware/auth.ts` instead of inline permission checks; constants come from `@filcdev/api/permissions`. Never inline `'resource:action'` string literals. +- Request/response zod wire schemas live in `packages/api/src/domains/` (`@filcdev/api/domains/`) so client apps share them — import from there instead of re-declaring. Schemas derived from Drizzle tables stay local to Chronos. +- OpenAPI metadata ships with the contract change, not the handler. After touching any contract file, regenerate the document: `bun run openapi:generate` in `apps/chronos` (rewrites `openapi/chronos-openapi.json`, served at `/api/doc/openapi.json`). diff --git a/.agents/skills/chronos-schema-procedure/SKILL.md b/.agents/skills/chronos-schema-procedure/SKILL.md index e4a45c73..48a75dcb 100644 --- a/.agents/skills/chronos-schema-procedure/SKILL.md +++ b/.agents/skills/chronos-schema-procedure/SKILL.md @@ -1,12 +1,12 @@ --- name: chronos-schema-procedure -description: Procedures for editing Chronos Drizzle schema files, table relationships, indexes, timestamps, and preparing migrations from apps/chronos/src/database/schema. Use when adding or altering tables or columns, changing relations, or generating database migrations. +description: Procedures for editing Chronos Drizzle schema tables, columns, relations, indexes, or generating database migrations. Use when adding or altering tables or columns in module schemas, changing relations, or preparing migrations with the db:* scripts. --- # Chronos Schema Procedure -- Change TypeScript schema sources under `apps/chronos/src/database/schema`; never hand-edit generated SQL under `apps/chronos/src/database/migrations`. +- Feature tables live in `apps/chronos/src/modules//schema.ts`; only the shared identity and RBAC tables stay in `apps/chronos/src/database/schema` (`authentication.ts`, `authorization.ts`, `api-keys.ts` — every other table has a foreign key to them). Never hand-edit generated SQL under `apps/chronos/src/database/migrations`. - Reuse shared column helpers from `apps/chronos/src/database/helpers.ts` instead of re-declaring timestamp columns. -- Follow the table style in `apps/chronos/src/database/schema/doorlock.ts`: explicit foreign keys, intentional `onDelete` behavior, and indexes or composite keys where the relationship needs them. -- Keep exported schema collections updated when a new table belongs to an existing domain module. -- After intentional schema changes, generate the migration with the `db:*` scripts from `apps/chronos/package.json`. -- If backend route or frontend form code depends on a changed shape, update those callers in the same change rather than leaving the repo in a contract mismatch. Wire-facing response shapes may belong in `packages/api/src/domains` — update those contracts together with the tables. +- Follow the table style in `apps/chronos/src/modules/doorlock/schema.ts`: explicit foreign keys, intentional `onDelete` behavior, and indexes or composite keys where the relationship needs them. +- A module's tables are exported as a schema map from its `schema.ts` and MUST be added to `moduleSchemas` in `apps/chronos/src/modules/schemas.ts` — that list is what Drizzle's runtime schema is built from (it stays separate from the module manifests so `database/index.ts` can import it without creating an import cycle). +- After intentional schema changes, generate the migration with the `db:*` scripts in `apps/chronos/package.json`. +- If backend route or frontend form code depends on a changed shape, update those callers in the same change rather than leaving a contract mismatch. Wire-facing response shapes live in `packages/api/src/domains` — update those contracts together with the tables. diff --git a/.agents/skills/iris-data-flow/SKILL.md b/.agents/skills/iris-data-flow/SKILL.md index fba68637..edcb56ea 100644 --- a/.agents/skills/iris-data-flow/SKILL.md +++ b/.agents/skills/iris-data-flow/SKILL.md @@ -6,7 +6,7 @@ description: Procedures for adding or changing Iris route pages, React Query que - Follow route composition patterns from files like `apps/iris/src/routes/_private/admin/timetable/substitutions.tsx`: `createFileRoute(...)` at the top, then permission gating, then page state (sort/filters/dialogs) fed by domain hooks. - Domain data access lives in hook modules at `apps/iris/src/hooks/.ts` (see `substitutions.ts`): `use` query hooks plus `useCreateX`/`useUpdateX`/`useDeleteX` mutation hooks. Hooks own the API call, translated toasts, and invalidation of every affected family; mutation hooks take `{ onSaved?: () => void }`. -- Routes contain no inline `parseResponse`/`useMutation` blocks — they consume the domain hooks and keep only UI state (sort, filters, dialog open/close, selection). Reference: `apps/iris/src/routes/_private/admin/timetable/substitutions.tsx`. -- Multi-parameter query keys take a single object argument (e.g. `queryKeys.doorlock.logs({ deviceFilter, search, ... })`) so React Query partial matching works. +- Routes contain no inline `useMutation` blocks — they consume the domain hooks and keep only UI state (sort, filters, dialog open/close, selection). Reference: `apps/iris/src/routes/_private/admin/timetable/substitutions.tsx`. +- There are no hand-written query keys. Queries use `orpc...queryOptions({ input })` and invalidation uses `queryClient.invalidateQueries({ queryKey: orpc...key({ input }) })` — a bare `.key()` matches every input of that procedure, and `queryKey: [...orpc.x.y.key({...}), extra]` scopes beyond the procedure's own input (see `apps/iris/src/hooks/notifications.ts`). - Reuse `apps/iris/src/hooks/use-has-permission.ts` and existing permission guard components instead of duplicating permission logic in views; permission constants come from `@filcdev/api/permissions`. - New user-facing error and success messages go through `t(...)` and both locale trees (`apps/iris/public/locales/en`, `apps/iris/public/locales/hu`), even when surfaced through toasts. diff --git a/apps/iris/src/components/subs-view.tsx b/apps/iris/src/components/subs-view.tsx index e24e819a..51296b9a 100644 --- a/apps/iris/src/components/subs-view.tsx +++ b/apps/iris/src/components/subs-view.tsx @@ -486,7 +486,7 @@ function SubsFilterBar({ } /> diff --git a/apps/iris/src/components/system-message-banner.tsx b/apps/iris/src/components/system-message-banner.tsx index 4c5c54d2..2060a27c 100644 --- a/apps/iris/src/components/system-message-banner.tsx +++ b/apps/iris/src/components/system-message-banner.tsx @@ -84,7 +84,7 @@ export function SystemMessageBanner() { const until = formatLocalizedDate(message.validUntil, i18n.language); return ( -
    +

    diff --git a/apps/iris/src/components/timetable/filter-bar.tsx b/apps/iris/src/components/timetable/filter-bar.tsx index bf6d75ed..f9d3add6 100644 --- a/apps/iris/src/components/timetable/filter-bar.tsx +++ b/apps/iris/src/components/timetable/filter-bar.tsx @@ -250,7 +250,7 @@ export function FilterBar({ } /> diff --git a/apps/iris/src/routes/_private/admin/bug-reports.tsx b/apps/iris/src/routes/_private/admin/bug-reports.tsx index f03763bc..4ccd9176 100644 --- a/apps/iris/src/routes/_private/admin/bug-reports.tsx +++ b/apps/iris/src/routes/_private/admin/bug-reports.tsx @@ -439,7 +439,7 @@ function AdminBugReportsPage() {

    {t('bugReports.description')}
    -
    +
    {selected.description}
    diff --git a/packages/ui/src/components/table.tsx b/packages/ui/src/components/table.tsx index 42ef0a01..0b4ce82e 100644 --- a/packages/ui/src/components/table.tsx +++ b/packages/ui/src/components/table.tsx @@ -66,7 +66,7 @@ function TableHead({ className, ...props }: ComponentProps<'th'>) { return ( ) { return ( Date: Thu, 8 Oct 2026 13:10:02 +0200 Subject: [PATCH 26/32] chore: replace husky with left-hook --- .husky/install.ts | 8 -------- .husky/pre-commit | 11 ----------- bun.lock | 35 +++++++++++++++++++++++------------ lefthook.yml | 6 ++++++ package.json | 8 ++------ 5 files changed, 31 insertions(+), 37 deletions(-) delete mode 100644 .husky/install.ts delete mode 100644 .husky/pre-commit create mode 100644 lefthook.yml diff --git a/.husky/install.ts b/.husky/install.ts deleted file mode 100644 index aa37409f..00000000 --- a/.husky/install.ts +++ /dev/null @@ -1,8 +0,0 @@ -import process from 'node:process'; - -if (process.env.NODE_ENV === 'production' || process.env.CI === 'true') { - process.exit(0); -} -const husky = (await import('husky')).default; -// biome-ignore lint/suspicious/noConsole: fine here -console.log(husky()); diff --git a/.husky/pre-commit b/.husky/pre-commit deleted file mode 100644 index 6a7daffa..00000000 --- a/.husky/pre-commit +++ /dev/null @@ -1,11 +0,0 @@ -if command -v bunx >/dev/null 2>&1; then - bunx lint-staged && exit 0 -fi - -if command -v npx >/dev/null 2>&1; then - # Do not use --no-install here: on some host setups lint-staged is not in local node_modules. - npx lint-staged && exit 0 -fi - -echo "Warning: could not run lint-staged (bunx/npx unavailable or failed). Skipping pre-commit checks." -exit 0 diff --git a/bun.lock b/bun.lock index d6099163..ed5a60b7 100644 --- a/bun.lock +++ b/bun.lock @@ -6,8 +6,7 @@ "name": "@filcdev/filc", "devDependencies": { "@biomejs/biome": "^2.5.15", - "husky": "^9.1.7", - "lint-staged": "^17.6.0", + "lefthook": "^2.2.1", "turbo": "^2.11.7", "typescript": "catalog:", }, @@ -1451,8 +1450,6 @@ "human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], - "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], - "hyphen": ["hyphen@1.6.6", "", {}, "sha512-XtqmnT+b9n5MX+MsqluFAVTIenbtC25iskW0Z+jLd+awfhA+ZbWKWQMIvLJccGoa2bM1R6juWJ27cZxIFOmkWw=="], "i18next": ["i18next@26.4.2", "", { "peerDependencies": { "typescript": "^5 || ^6 || ^7" }, "optionalPeers": ["typescript"] }, "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug=="], @@ -1551,6 +1548,28 @@ "launch-editor": ["launch-editor@2.14.1", "", { "dependencies": { "picocolors": "^1.1.1", "shell-quote": "^1.8.4" } }, "sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA=="], + "lefthook": ["lefthook@2.2.1", "", { "optionalDependencies": { "lefthook-darwin-arm64": "2.2.1", "lefthook-darwin-x64": "2.2.1", "lefthook-freebsd-arm64": "2.2.1", "lefthook-freebsd-x64": "2.2.1", "lefthook-linux-arm64": "2.2.1", "lefthook-linux-x64": "2.2.1", "lefthook-openbsd-arm64": "2.2.1", "lefthook-openbsd-x64": "2.2.1", "lefthook-windows-arm64": "2.2.1", "lefthook-windows-x64": "2.2.1" }, "bin": { "lefthook": "bin/index.js" } }, "sha512-aKEwhQzGbLMkZu5vrFYKJzLvLVblWATYITmN7Ak4qRCDPCoFJhmeJZFuYf3nTXuqG9xTBE+J8xaAEPL2tpuWyw=="], + + "lefthook-darwin-arm64": ["lefthook-darwin-arm64@2.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-SyuDBT5HqNQfi607vMHrYJu45bRN7JoIFdyxLjEPVe34UnZJCesmeYBODEYTyCfRZjPOaTbton4Padu+qvqfCQ=="], + + "lefthook-darwin-x64": ["lefthook-darwin-x64@2.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-qkIdM89T3OxpLp9rssPSdRQ7QKkX0l07xWxxOaZfECn5iB2xVXSD9FQSjYUMNGXtJJWOGqdKqBngGTB/ShDZtQ=="], + + "lefthook-freebsd-arm64": ["lefthook-freebsd-arm64@2.2.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-0jdr/9Q5cGuazrEVquv29AOD77aPc2jAmFU4EtqVVg3PhMJjAyt7bQeA9qdwHhaHhLnaeUZwMwo3IleVm+D4wg=="], + + "lefthook-freebsd-x64": ["lefthook-freebsd-x64@2.2.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JFKyNZ9qQp9NsuFyAg/CJIBurIHX3AUayFXxuoDMATWAIhhlyK0YMtpWgEFIl1M2iGLBLQ3TiLoCCRnbwndNDg=="], + + "lefthook-linux-arm64": ["lefthook-linux-arm64@2.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-na8Ezm7pK44+WsQqj9tES7v8VseM1MrNeNJhUnM8eINV22KYptWam8evfjZxY3NYxOTwn6f/Zpe/BBvcLKMaTQ=="], + + "lefthook-linux-x64": ["lefthook-linux-x64@2.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-wl7CR5uNkOiZ38SkW4l9O6wf/Nllbkw90ZZonXRFkbgrnPjLMhZGUt9ucewsPwBQ/ckU1t08JuBHeOywB/CFow=="], + + "lefthook-openbsd-arm64": ["lefthook-openbsd-arm64@2.2.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-UFwL+AGSqkhQ15kuKl5FMRx/UqbcfcQbGFg5ggYzhT7qBOsTu3a9POXRxt3hsalBeMgkDWsv3IaPlj51YGpCAg=="], + + "lefthook-openbsd-x64": ["lefthook-openbsd-x64@2.2.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-IMKEuxJLo91C1yjhJ42EadfXUb3ECMkvTKpSt17kwaiyZsvvZ8LGdrXwQZiLNJt2M5ECuTVCQUvDimM6mGraOw=="], + + "lefthook-windows-arm64": ["lefthook-windows-arm64@2.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-Pi8vOvObjg8HJDl8d96s7jsI6TN33klpqv6tXSXpoKEeoAwEh5Y9NawO6bzoOpLfAxf2SfMBcuaBzFQ5zE2gjA=="], + + "lefthook-windows-x64": ["lefthook-windows-x64@2.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-7FtuPrc1XzxIFLAbzneOF1hr9r/vGi2lcMpX14QG9d/mnLhwu4EyNBh48JNEqsQqrOB9QKavsNqxVl1e8zcukA=="], + "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], @@ -1579,8 +1598,6 @@ "linebreak": ["linebreak@1.1.0", "", { "dependencies": { "base64-js": "0.0.8", "unicode-trie": "^2.0.0" } }, "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ=="], - "lint-staged": ["lint-staged@17.6.0", "", { "dependencies": { "picomatch": "^4.0.7", "string-argv": "^0.3.2", "tinyexec": "^1.3.1" }, "optionalDependencies": { "yaml": "^2.9.1" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-nOhBfYkZEwkFtNzD5pK9s9LkcWoxJ+whLzEnhqi9nl7zTB5n7tg8+9iYgMGx2Wm3VlLu8cbNj0RACzY+nWi5iQ=="], - "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], "lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="], @@ -1937,8 +1954,6 @@ "stream-shift": ["stream-shift@1.0.3", "", {}, "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ=="], - "string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="], - "string-ts": ["string-ts@2.3.1", "", {}, "sha512-xSJq+BS52SaFFAVxuStmx6n5aYZU571uYUnUrPXkPFCfdHyZMMlbP2v2Wx5sNBnAVzq/2+0+mcBLBa3Xa5ubYw=="], "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -1983,8 +1998,6 @@ "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], - "tinyexec": ["tinyexec@1.3.1", "", {}, "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA=="], - "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], @@ -2097,8 +2110,6 @@ "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - "yaml": ["yaml@2.9.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw=="], - "yargs": ["yargs@17.7.3", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g=="], "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 00000000..91e44e02 --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,6 @@ +pre-commit: + jobs: + - name: biome + glob: "*.{ts,tsx,js,jsx,json,css}" + run: bunx biome check --write {staged_files} + stage_fixed: true diff --git a/package.json b/package.json index ff50b818..4865f544 100644 --- a/package.json +++ b/package.json @@ -2,14 +2,10 @@ "name": "@filcdev/filc", "devDependencies": { "@biomejs/biome": "^2.5.15", - "husky": "^9.1.7", - "lint-staged": "^17.6.0", + "lefthook": "^2.2.1", "turbo": "^2.11.7", "typescript": "catalog:" }, - "lint-staged": { - "*.{ts,tsx,js,jsx,json,css}": "bunx biome check --write" - }, "packageManager": "bun@1.4.2", "private": true, "scripts": { @@ -17,7 +13,7 @@ "lint": "biome check --write .", "build": "turbo run build", "typecheck": "turbo run typecheck", - "prepare": "bun run .husky/install.ts || true" + "prepare": "lefthook install --reset-hooks-path || true" }, "workspaces": { "packages": [ From f7274b6323c9a32ad9e815c00a5d30b0948bfdaf Mon Sep 17 00:00:00 2001 From: Tamas Vince Date: Mon, 12 Oct 2026 11:24:35 +0200 Subject: [PATCH 27/32] feat: migrate to separate tsconfig package --- apps/chronos/build.ts | 4 ++++ apps/chronos/package.json | 2 +- apps/chronos/tsconfig.json | 25 ++++--------------- apps/iris/package.json | 2 +- apps/iris/tsconfig.json | 32 +++---------------------- apps/kiosk/package.json | 2 +- apps/kiosk/tsconfig.json | 31 +++--------------------- package.json | 1 - packages/api/package.json | 2 +- packages/api/src/client/index.ts | 2 ++ packages/api/tsconfig.json | 17 +------------ packages/auth/package.json | 2 +- packages/auth/tsconfig.json | 17 +------------ packages/navigator-3d/package.json | 2 +- packages/navigator-3d/tsconfig.json | 28 ++-------------------- packages/timetable-import/package.json | 2 +- packages/timetable-import/tsconfig.json | 17 +------------ packages/tsconfig/base.json | 14 +++++++++++ packages/tsconfig/node.json | 7 ++++++ packages/tsconfig/package.json | 8 +++++++ packages/tsconfig/react-library.json | 8 +++++++ packages/tsconfig/vite-react.json | 12 ++++++++++ packages/ui/package.json | 2 +- packages/ui/tsconfig.json | 28 ++-------------------- 24 files changed, 80 insertions(+), 187 deletions(-) create mode 100644 packages/tsconfig/base.json create mode 100644 packages/tsconfig/node.json create mode 100644 packages/tsconfig/package.json create mode 100644 packages/tsconfig/react-library.json create mode 100644 packages/tsconfig/vite-react.json diff --git a/apps/chronos/build.ts b/apps/chronos/build.ts index 528ab4bd..3d2aca8f 100644 --- a/apps/chronos/build.ts +++ b/apps/chronos/build.ts @@ -12,3 +12,7 @@ await Bun.build({ }); console.timeEnd('Built Chronos server'); + +// we must make this file a module +// in order to make top-level export work +export {}; diff --git a/apps/chronos/package.json b/apps/chronos/package.json index 2b246a09..0d7cfc61 100644 --- a/apps/chronos/package.json +++ b/apps/chronos/package.json @@ -40,10 +40,10 @@ "zod": "catalog:" }, "devDependencies": { + "@filcdev/tsconfig": "workspace:*", "@inquirer/prompts": "^8.7.3", "@logtape/pretty": "^2.4.0", "@openapi-spec/downgrader": "^0.3.0", - "@tsconfig/strictest": "catalog:", "@types/bun": "catalog:", "@types/cli-progress": "^3.11.6", "@types/nodemailer": "^8.0.2", diff --git a/apps/chronos/tsconfig.json b/apps/chronos/tsconfig.json index d080fd6a..31f132f2 100644 --- a/apps/chronos/tsconfig.json +++ b/apps/chronos/tsconfig.json @@ -1,26 +1,9 @@ { - "extends": "@tsconfig/strictest/tsconfig.json", + "extends": "@filcdev/tsconfig/node.json", "compilerOptions": { - "strict": true, - "skipLibCheck": true, - "noPropertyAccessFromIndexSignature": false, - "noFallthroughCasesInSwitch": true, - "noUncheckedIndexedAccess": true, - "noImplicitOverride": true, - "noUnusedLocals": false, // biome handles this - "noUnusedParameters": true, - "allowSyntheticDefaultImports": true, - "types": ["bun"], - "outDir": "dist", - "target": "es2022", - "module": "preserve", - "moduleResolution": "bundler", - "moduleDetection": "force", - "verbatimModuleSyntax": true, - "noEmit": true, - "paths": { - "#*": ["./src/*"] + "@/*": ["./src/*"] } - } + }, + "include": ["src", "scripts", "drizzle.config.ts", "build.ts"] } diff --git a/apps/iris/package.json b/apps/iris/package.json index 53571aea..5a91e383 100644 --- a/apps/iris/package.json +++ b/apps/iris/package.json @@ -45,9 +45,9 @@ "zod": "catalog:" }, "devDependencies": { + "@filcdev/tsconfig": "workspace:*", "@filcdev/api": "workspace:*", "@tanstack/devtools-vite": "^0.8.5", - "@tsconfig/strictest": "catalog:", "@types/bun": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", diff --git a/apps/iris/tsconfig.json b/apps/iris/tsconfig.json index d54b8665..990cf07c 100644 --- a/apps/iris/tsconfig.json +++ b/apps/iris/tsconfig.json @@ -1,35 +1,9 @@ { - "extends": "@tsconfig/strictest/tsconfig.json", - "include": ["**/*.ts", "**/*.tsx"], - "exclude": ["node_modules"], + "extends": "@filcdev/tsconfig/vite-react.json", "compilerOptions": { - "target": "ES2022", - "jsx": "react-jsx", - "module": "ESNext", - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "types": ["vite/client", "bun"], - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "resolveJsonModule": true, - "verbatimModuleSyntax": true, - "noEmit": true, - - /* shadcn */ - "exactOptionalPropertyTypes": false, - - /* Linting */ - "skipLibCheck": true, - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true, - "noPropertyAccessFromIndexSignature": false, - "paths": { "@/*": ["./src/*"] } - } + }, + "include": ["src", "vite.config.ts"] } diff --git a/apps/kiosk/package.json b/apps/kiosk/package.json index c3a28356..4f7cdc86 100644 --- a/apps/kiosk/package.json +++ b/apps/kiosk/package.json @@ -24,9 +24,9 @@ "zod": "catalog:" }, "devDependencies": { + "@filcdev/tsconfig": "workspace:*", "@filcdev/api": "workspace:*", "@tanstack/router-plugin": "^1.168.42", - "@tsconfig/strictest": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", "@vitejs/plugin-react": "catalog:", diff --git a/apps/kiosk/tsconfig.json b/apps/kiosk/tsconfig.json index 661100fe..990cf07c 100644 --- a/apps/kiosk/tsconfig.json +++ b/apps/kiosk/tsconfig.json @@ -1,34 +1,9 @@ { - "extends": "@tsconfig/strictest/tsconfig.json", - "include": ["**/*.ts", "**/*.tsx"], - "exclude": ["node_modules"], + "extends": "@filcdev/tsconfig/vite-react.json", "compilerOptions": { - "target": "ES2022", - "jsx": "react-jsx", - "module": "ESNext", - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "types": ["vite/client"], - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "noEmit": true, - - /* shadcn */ - "exactOptionalPropertyTypes": false, - - /* Linting */ - "skipLibCheck": true, - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true, - "noPropertyAccessFromIndexSignature": false, - "paths": { "@/*": ["./src/*"] } - } + }, + "include": ["src", "vite.config.ts"] } diff --git a/package.json b/package.json index 4865f544..10b06091 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,6 @@ "@tanstack/react-form": "^1.33.5", "@tanstack/react-query": "^5.104.1", "@tanstack/react-router": "^1.170.41", - "@tsconfig/strictest": "^2.0.8", "@types/bun": "^1.4.2", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", diff --git a/packages/api/package.json b/packages/api/package.json index 5dd9248f..dc95031c 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -21,7 +21,7 @@ "zod": "catalog:" }, "devDependencies": { - "@tsconfig/strictest": "catalog:", + "@filcdev/tsconfig": "workspace:*", "@types/bun": "catalog:", "typescript": "catalog:" } diff --git a/packages/api/src/client/index.ts b/packages/api/src/client/index.ts index 0150ba0a..b56945d7 100644 --- a/packages/api/src/client/index.ts +++ b/packages/api/src/client/index.ts @@ -8,6 +8,8 @@ import type { appContract } from '../contract'; export type ApiClient = ContractRouterClient; +type RequestCredentials = 'include' | 'omit' | 'same-origin'; + export type ApiClientOptions = { /** * Base RPC URL. A function is re-evaluated per call, so a server render can diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json index 74bba21c..d8416826 100644 --- a/packages/api/tsconfig.json +++ b/packages/api/tsconfig.json @@ -1,19 +1,4 @@ { - "extends": "@tsconfig/strictest/tsconfig.json", - "compilerOptions": { - "strict": true, - "skipLibCheck": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedIndexedAccess": true, - "noImplicitOverride": true, - "allowSyntheticDefaultImports": true, - "types": ["bun"], - "target": "es2022", - "module": "preserve", - "moduleResolution": "bundler", - "moduleDetection": "force", - "verbatimModuleSyntax": true, - "noEmit": true - }, + "extends": "@filcdev/tsconfig/node.json", "include": ["src"] } diff --git a/packages/auth/package.json b/packages/auth/package.json index 24cb8186..6a033061 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -18,7 +18,7 @@ "react": "catalog:" }, "devDependencies": { - "@tsconfig/strictest": "catalog:", + "@filcdev/tsconfig": "workspace:*", "@types/bun": "catalog:", "@types/react": "catalog:", "typescript": "catalog:" diff --git a/packages/auth/tsconfig.json b/packages/auth/tsconfig.json index 74bba21c..d8416826 100644 --- a/packages/auth/tsconfig.json +++ b/packages/auth/tsconfig.json @@ -1,19 +1,4 @@ { - "extends": "@tsconfig/strictest/tsconfig.json", - "compilerOptions": { - "strict": true, - "skipLibCheck": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedIndexedAccess": true, - "noImplicitOverride": true, - "allowSyntheticDefaultImports": true, - "types": ["bun"], - "target": "es2022", - "module": "preserve", - "moduleResolution": "bundler", - "moduleDetection": "force", - "verbatimModuleSyntax": true, - "noEmit": true - }, + "extends": "@filcdev/tsconfig/node.json", "include": ["src"] } diff --git a/packages/navigator-3d/package.json b/packages/navigator-3d/package.json index 8e2bd7bf..9bc9e1d5 100644 --- a/packages/navigator-3d/package.json +++ b/packages/navigator-3d/package.json @@ -20,7 +20,7 @@ "react": "catalog:" }, "devDependencies": { - "@tsconfig/strictest": "catalog:", + "@filcdev/tsconfig": "workspace:*", "@types/react": "catalog:", "@types/three": "^0.186.0", "typescript": "catalog:" diff --git a/packages/navigator-3d/tsconfig.json b/packages/navigator-3d/tsconfig.json index 556b2a39..7c3c72c4 100644 --- a/packages/navigator-3d/tsconfig.json +++ b/packages/navigator-3d/tsconfig.json @@ -1,28 +1,4 @@ { - "extends": "@tsconfig/strictest/tsconfig.json", - "include": ["**/*.ts", "**/*.tsx"], - "exclude": ["node_modules"], - "compilerOptions": { - "target": "ES2022", - "jsx": "react-jsx", - "module": "ESNext", - "lib": ["ES2022", "DOM", "DOM.Iterable"], - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "noEmit": true, - - "exactOptionalPropertyTypes": false, - - /* Linting */ - "skipLibCheck": true, - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true, - "noPropertyAccessFromIndexSignature": false - } + "extends": "@filcdev/tsconfig/react-library.json", + "include": ["src"] } diff --git a/packages/timetable-import/package.json b/packages/timetable-import/package.json index 6d55a5ad..6bd88581 100644 --- a/packages/timetable-import/package.json +++ b/packages/timetable-import/package.json @@ -21,7 +21,7 @@ "zod": "catalog:" }, "devDependencies": { - "@tsconfig/strictest": "catalog:", + "@filcdev/tsconfig": "workspace:*", "@types/bun": "catalog:", "typescript": "catalog:" } diff --git a/packages/timetable-import/tsconfig.json b/packages/timetable-import/tsconfig.json index 74bba21c..d8416826 100644 --- a/packages/timetable-import/tsconfig.json +++ b/packages/timetable-import/tsconfig.json @@ -1,19 +1,4 @@ { - "extends": "@tsconfig/strictest/tsconfig.json", - "compilerOptions": { - "strict": true, - "skipLibCheck": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedIndexedAccess": true, - "noImplicitOverride": true, - "allowSyntheticDefaultImports": true, - "types": ["bun"], - "target": "es2022", - "module": "preserve", - "moduleResolution": "bundler", - "moduleDetection": "force", - "verbatimModuleSyntax": true, - "noEmit": true - }, + "extends": "@filcdev/tsconfig/node.json", "include": ["src"] } diff --git a/packages/tsconfig/base.json b/packages/tsconfig/base.json new file mode 100644 index 00000000..18da2811 --- /dev/null +++ b/packages/tsconfig/base.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "moduleResolution": "bundler", + "isolatedModules": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true + } +} diff --git a/packages/tsconfig/node.json b/packages/tsconfig/node.json new file mode 100644 index 00000000..678ba3ab --- /dev/null +++ b/packages/tsconfig/node.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./base.json", + "compilerOptions": { + "types": ["bun"] + } +} diff --git a/packages/tsconfig/package.json b/packages/tsconfig/package.json new file mode 100644 index 00000000..6cbe5a4a --- /dev/null +++ b/packages/tsconfig/package.json @@ -0,0 +1,8 @@ +{ + "name": "@filcdev/tsconfig", + "version": "0.0.0", + "private": true, + "files": [ + "*.json" + ] +} diff --git a/packages/tsconfig/react-library.json b/packages/tsconfig/react-library.json new file mode 100644 index 00000000..270a431b --- /dev/null +++ b/packages/tsconfig/react-library.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./base.json", + "compilerOptions": { + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "jsx": "react-jsx" + } +} diff --git a/packages/tsconfig/vite-react.json b/packages/tsconfig/vite-react.json new file mode 100644 index 00000000..a11b139a --- /dev/null +++ b/packages/tsconfig/vite-react.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./react-library.json", + "compilerOptions": { + "types": ["vite/client"], + "allowImportingTsExtensions": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + "noUnusedLocals": true, + "noUnusedParameters": true + } +} diff --git a/packages/ui/package.json b/packages/ui/package.json index 902a01cf..830e45ff 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -37,7 +37,7 @@ "react": "catalog:" }, "devDependencies": { - "@tsconfig/strictest": "catalog:", + "@filcdev/tsconfig": "workspace:*", "@types/react": "catalog:", "tailwindcss": "catalog:", "typescript": "catalog:" diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index 556b2a39..7c3c72c4 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -1,28 +1,4 @@ { - "extends": "@tsconfig/strictest/tsconfig.json", - "include": ["**/*.ts", "**/*.tsx"], - "exclude": ["node_modules"], - "compilerOptions": { - "target": "ES2022", - "jsx": "react-jsx", - "module": "ESNext", - "lib": ["ES2022", "DOM", "DOM.Iterable"], - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "noEmit": true, - - "exactOptionalPropertyTypes": false, - - /* Linting */ - "skipLibCheck": true, - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true, - "noPropertyAccessFromIndexSignature": false - } + "extends": "@filcdev/tsconfig/react-library.json", + "include": ["src"] } From 21a5a50fb4848a8719bb5a28683c7cf05dc34099 Mon Sep 17 00:00:00 2001 From: Tamas Vince Date: Fri, 16 Oct 2026 09:38:12 +0200 Subject: [PATCH 28/32] feat: mail package with oauth --- AGENTS.md | 1 + apps/chronos/.env.example | 21 ++ apps/chronos/package.json | 11 +- .../modules/notifications/notifications.ts | 8 +- apps/chronos/src/utils/environment.ts | 1 + .../src/utils/notifications/providers/smtp.ts | 217 +++++++----------- .../providers/templates/body.hbs | 1 - .../providers/templates/layout.hbs | 118 ---------- apps/chronos/src/utils/notifications/queue.ts | 1 - apps/chronos/tsconfig.json | 1 + biome.jsonc | 3 - bun.lock | 134 +++++++++-- packages/mail/package.json | 28 +++ packages/mail/src/index.ts | 154 +++++++++++++ packages/mail/src/templates/email-layout.tsx | 73 ++++++ packages/mail/src/templates/i18n.ts | 28 +++ packages/mail/src/templates/notification.tsx | 30 +++ packages/mail/src/templates/render.ts | 14 ++ packages/mail/src/templates/styles.ts | 104 +++++++++ packages/mail/src/templates/types.ts | 23 ++ packages/mail/tsconfig.json | 4 + 21 files changed, 689 insertions(+), 286 deletions(-) delete mode 100644 apps/chronos/src/utils/notifications/providers/templates/body.hbs delete mode 100644 apps/chronos/src/utils/notifications/providers/templates/layout.hbs create mode 100644 packages/mail/package.json create mode 100644 packages/mail/src/index.ts create mode 100644 packages/mail/src/templates/email-layout.tsx create mode 100644 packages/mail/src/templates/i18n.ts create mode 100644 packages/mail/src/templates/notification.tsx create mode 100644 packages/mail/src/templates/render.ts create mode 100644 packages/mail/src/templates/styles.ts create mode 100644 packages/mail/src/templates/types.ts create mode 100644 packages/mail/tsconfig.json diff --git a/AGENTS.md b/AGENTS.md index 16022c17..7c46118f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,7 @@ - [apps/chronos](apps/chronos) is the oRPC + Drizzle backend: it implements the contract from `packages/api` and serves an RPC endpoint for the apps plus an OpenAPI surface for external clients. - [packages/api](packages/api) is the shared API contract package (`@filcdev/api`): the oRPC contract, the zod wire schemas per domain, the error map, permission constants and the typed client factory. Chronos implements the contract, iris and kiosk consume it, and it is the only place endpoint metadata lives. Future apps calling the Chronos API should depend on this package instead of the Chronos workspace. - [packages/auth](packages/auth) is the shared auth surface (`@filcdev/auth`): the better-auth user fields, the session/user types and the browser client (`authClient`, `useSession`). Chronos passes the fields to better-auth and the apps read the client; `apps/chronos/src/_types/auth.ts` proves at compile time that the two still agree. +- [packages/mail](packages/mail) is the shared email package (`@filcdev/mail`): the SMTP transport (`createMailTransport`) and the react-email templates (`renderEmail`), carrying the Filc email styling and copy. Chronos renders and sends every notification email through it ([apps/chronos/src/utils/notifications/providers/smtp.ts](apps/chronos/src/utils/notifications/providers/smtp.ts) wires env and the per-type labels into it), so template markup and email copy live in `packages/mail/src/templates`. - [packages/ui](packages/ui) is the shared design system (`@filcdev/ui`): the shadcn-style primitives, `cn`, `useIsMobile` and the theme tokens in `globals.css`. Iris imports them as `@filcdev/ui/components/*`, `@filcdev/ui/lib/utils`, `@filcdev/ui/hooks/*` and `@import "@filcdev/ui/globals.css"` (plus an `@source` for the package). App-agnostic primitives belong here; app-specific composition stays in the app. - [apps/iris](apps/iris) is the React + Vite frontend. diff --git a/apps/chronos/.env.example b/apps/chronos/.env.example index b6f9be1e..a2a905d2 100644 --- a/apps/chronos/.env.example +++ b/apps/chronos/.env.example @@ -77,6 +77,27 @@ CHRONOS_ENTRA_CLIENT_SECRET=FILL_ME_IN # CHRONOS_SMTP_FROM_EMAIL=noreply@filc.hu # CHRONOS_SMTP_SECURE=false +# SMTP authentication mode: "basic" (a user + password, or none at all for a +# local relay like Mailpit) or "oauth2" (SASL XOAUTH2, required by Microsoft 365 +# and Outlook.com, which reject basic auth). Defaults to "basic". +# CHRONOS_SMTP_AUTH=basic + +# Sending from Exchange Online / Outlook.com (SMTP AUTH OAuth 2.0): +# CHRONOS_SMTP_AUTH=oauth2 +# CHRONOS_SMTP_HOST=smtp.office365.com +# CHRONOS_SMTP_PORT=587 +# CHRONOS_SMTP_SECURE=false +# CHRONOS_SMTP_USER=school@example.com (the mailbox to send as; falls back to +# CHRONOS_SMTP_FROM_EMAIL when unset) +# CHRONOS_SMTP_FROM_NAME=Filc +# CHRONOS_SMTP_FROM_EMAIL=school@example.com +# OAuth2 mints the bearer token with the Entra app above (CHRONOS_ENTRA_CLIENT_ID, +# CHRONOS_ENTRA_CLIENT_SECRET, CHRONOS_ENTRA_TENANT_ID) via the client-credentials +# grant. The app needs the "Office 365 Exchange Online" application permission +# SMTP.SendAsApp, a service principal registered in Exchange +# (New-ServicePrincipal) and access granted on the sending mailbox +# (Add-MailboxPermission, plus Add-RecipientPermission for SendAs). + # Firebase Cloud Messaging for push notifications # CHRONOS_FCM_CREDENTIALS={"type":"service_account",...} # CHRONOS_FCM_PROJECT_ID=your-firebase-project-id diff --git a/apps/chronos/package.json b/apps/chronos/package.json index 0d7cfc61..25aaed1a 100644 --- a/apps/chronos/package.json +++ b/apps/chronos/package.json @@ -18,6 +18,7 @@ "@better-auth/drizzle-adapter": "^1.7.7", "@filcdev/api": "workspace:*", "@filcdev/auth": "workspace:*", + "@filcdev/mail": "workspace:*", "@filcdev/timetable-import": "workspace:*", "@logtape/logtape": "^2.4.0", "@orpc/contract": "catalog:", @@ -33,9 +34,7 @@ "drizzle-zod": "^0.8.3", "fast-xml-parser": "catalog:", "firebase-admin": "^14.5.0", - "handlebars": "^4.7.10", "jose": "^6.2.12", - "nodemailer": "^10.0.15", "string-ts": "^2.3.1", "zod": "catalog:" }, @@ -46,16 +45,14 @@ "@openapi-spec/downgrader": "^0.3.0", "@types/bun": "catalog:", "@types/cli-progress": "^3.11.6", - "@types/nodemailer": "^8.0.2", "cli-progress": "^3.12.0", "drizzle-kit": "^0.31.11", "pg": "^8.23.1", "typescript": "catalog:" }, "imports": { - "#*": [ - "./src/*.ts", - "./src/*/index.ts" - ] + "#*": "./src/*.ts", + "#database": "./src/database/index.ts", + "#modules": "./src/modules/index.ts" } } diff --git a/apps/chronos/src/modules/notifications/notifications.ts b/apps/chronos/src/modules/notifications/notifications.ts index 80004935..7def3683 100644 --- a/apps/chronos/src/modules/notifications/notifications.ts +++ b/apps/chronos/src/modules/notifications/notifications.ts @@ -12,7 +12,10 @@ import { base } from '#orpc'; import { env } from '#utils/environment'; import { forbidden, notFound } from '#utils/http'; import { sendPush } from '#utils/notifications/providers/fcm'; -import { renderEmail, sendEmail } from '#utils/notifications/providers/smtp'; +import { + renderPreviewEmail, + sendEmail, +} from '#utils/notifications/providers/smtp'; import { enqueue } from '#utils/notifications/queue'; export const listNotifications = base.notifications.list @@ -372,7 +375,6 @@ export const sendTestNotification = base.notifications.sendTest if (body.channels.email) { results.email = await sendEmail( target.email, - subject, body.type, target.language, { content, title: subject }, @@ -401,7 +403,7 @@ export const previewTestNotification = base.notifications.previewTest body.content ?? 'This is a development test message. If you received this, everything works.'; - const html = await renderEmail(body.type, body.language ?? 'hu', { + const html = await renderPreviewEmail(body.type, body.language ?? 'hu', { content, title: subject, }); diff --git a/apps/chronos/src/utils/environment.ts b/apps/chronos/src/utils/environment.ts index 62df31dd..8f5b124c 100644 --- a/apps/chronos/src/utils/environment.ts +++ b/apps/chronos/src/utils/environment.ts @@ -75,6 +75,7 @@ const envShape = z.object({ .min(0) .max(1) .default(0.1), + CHRONOS_SMTP_AUTH: z.enum(['basic', 'oauth2']).default('basic'), CHRONOS_SMTP_FROM_EMAIL: z.string().optional(), CHRONOS_SMTP_FROM_NAME: z.string().optional(), diff --git a/apps/chronos/src/utils/notifications/providers/smtp.ts b/apps/chronos/src/utils/notifications/providers/smtp.ts index ab4a6140..22f18264 100644 --- a/apps/chronos/src/utils/notifications/providers/smtp.ts +++ b/apps/chronos/src/utils/notifications/providers/smtp.ts @@ -1,9 +1,12 @@ import { createHmac } from 'node:crypto'; +import { + createMailTransport, + type MailTransport, + renderEmail, + type SmtpAuth, +} from '@filcdev/mail'; import { getLogger } from '@logtape/logtape'; import { eq } from 'drizzle-orm'; -import Handlebars from 'handlebars'; -import type { Transporter } from 'nodemailer'; -import nodemailer from 'nodemailer'; import { db } from '#database'; import { user as userTable } from '#database/schema/authentication'; import { env } from '#utils/environment'; @@ -11,11 +14,13 @@ import type { NotificationType } from '#utils/notifications/types'; const logger = getLogger(['chronos', 'notifications', 'smtp']); -let transporter: Transporter | null = null; +const branding = { appName: 'Filc', appUrl: env.baseUrl }; -export function getTransporter(): Transporter | null { - if (transporter) { - return transporter; +let mailer: MailTransport | null = null; + +function getMailer(): MailTransport | null { + if (mailer) { + return mailer; } if (!(env.smtpHost && env.smtpFromEmail)) { @@ -23,57 +28,49 @@ export function getTransporter(): Transporter | null { return null; } + if (env.smtpAuth === 'oauth2' && !env.entraClientSecret) { + logger.warn( + 'SMTP OAuth2 needs a CHRONOS_ENTRA_CLIENT_SECRET, email delivery disabled' + ); + return null; + } + + let auth: SmtpAuth | undefined; + if (env.smtpAuth === 'oauth2') { + auth = { + entra: { + clientId: env.entraClientId, + clientSecret: env.entraClientSecret ?? '', + tenantId: env.entraTenantId, + }, + type: 'oauth2', + user: env.smtpUser ?? env.smtpFromEmail, + }; + } else if (env.smtpUser) { + auth = { pass: env.smtpPass, type: 'basic', user: env.smtpUser }; + } + try { - transporter = nodemailer.createTransport({ - auth: env.smtpUser - ? { - pass: env.smtpPass, - user: env.smtpUser, - } - : undefined, + mailer = createMailTransport({ + auth, + from: `"${env.smtpFromName ?? ''}" <${env.smtpFromEmail ?? ''}>`, host: env.smtpHost, port: env.smtpPort, secure: env.smtpSecure, }); - logger.info('SMTP transporter created', { + logger.info('SMTP transport created', { host: env.smtpHost, port: env.smtpPort, secure: env.smtpSecure, }); - return transporter; + return mailer; } catch (error) { - logger.error('Failed to create SMTP transporter', { error }); + logger.error('Failed to create SMTP transport', { error }); return null; } } -const localized = { - en: { - autoMessage: 'This is an automated message from Filc.', - greeting: 'Dear Filc user,', - unsubscribeLabel: 'Unsubscribe from notifications', - viewLabel: 'View in Filc', - }, - hu: { - autoMessage: 'Ez az email a Filc rendszer automatikus üzenete.', - greeting: 'Kedves Filc felhasználó!', - unsubscribeLabel: 'Leiratkozás az értesítésekről', - viewLabel: 'Megtekintés a Filcben', - }, -} as const; - -type Locale = keyof typeof localized; - -function localizedFor(locale: string): (typeof localized)[Locale] { - return localized[(locale as Locale) in localized ? (locale as Locale) : 'hu']; -} - -function typeLabelFor(type: NotificationType, locale: string): string { - const lang = localizedFor(locale) === localized.en ? 'en' : 'hu'; - return typeLabels[type]?.[lang] ?? type; -} - const typeLabels: Record = { announcement: { en: 'Announcement', hu: 'Közlemény' }, blog_post: { en: 'Blog post', hu: 'Blogbejegyzés' }, @@ -92,87 +89,53 @@ const typeLabels: Record = { test: { en: 'Test notification', hu: 'Teszt értesítés' }, }; -function buildGreeting( - name: string | null | undefined, - locale: string -): string { - const log = localizedFor(locale); - const trimmed = name?.trim(); - if (!trimmed) { - return log.greeting; - } - return log === localized.en ? `Dear ${trimmed},` : `Kedves ${trimmed}!`; -} - -/** - * The email is a single shared layout (`layout.hbs`) whose content slot is a - * plain body partial (`body.hbs`). The message body is always `{{content}}`, so - * there is no per-type or per-locale template to compile; the locale- and - * type-specific strings are supplied in the render context instead. - */ -let emailTemplate: HandlebarsTemplateDelegate | null = null; - -async function loadEmailTemplate(): Promise { - if (emailTemplate) { - return emailTemplate; - } - const [layoutSource, bodySource] = await Promise.all([ - Bun.file(`${import.meta.dir}/templates/layout.hbs`).text(), - Bun.file(`${import.meta.dir}/templates/body.hbs`).text(), - ]); - const layout = Handlebars.compile(layoutSource); - const body = Handlebars.compile(bodySource); - emailTemplate = (context) => layout(context, { partials: { content: body } }); - return emailTemplate; -} - export function generateUnsubscribeToken(userId: string): string { return createHmac('sha256', env.authSecret).update(userId).digest('hex'); } export async function sendEmail( to: string, - subject: string, type: NotificationType, locale: string, - templateData: Record, + body: { content: string; title: string }, userId: string ): Promise { - const tp = getTransporter(); + const tp = getMailer(); if (!tp) { logger.debug('SMTP not available, skipping email to {to}', { to }); return false; } try { - const unsubscribeToken = generateUnsubscribeToken(userId); - const log = localizedFor(locale); - - const [recipient] = await db - .select({ name: userTable.name }) - .from(userTable) - .where(eq(userTable.id, userId)) - .limit(1); - const greeting = buildGreeting(recipient?.name, locale); - - const template = await loadEmailTemplate(); - const html = template({ - ...templateData, - autoMessage: log.autoMessage, - baseUrl: env.baseUrl, - greeting, - lang: log === localized.en ? 'en' : 'hu', - typeLabel: typeLabelFor(type, locale), - unsubscribeLabel: log.unsubscribeLabel, - unsubscribeUrl: `${env.baseUrl}/unsubscribe?userId=${userId}&token=${unsubscribeToken}`, - viewLabel: log.viewLabel, - }); - - await tp.sendMail({ - from: `"${env.smtpFromName ?? ''}" <${env.smtpFromEmail ?? ''}>`, - html, - subject, - to, - }); + const lang = locale === 'en' ? 'en' : 'hu'; + // The id is a uuid column: the pure SMTP delivery test passes an empty id, + // and querying with it would abort the whole send. + const [recipient] = userId + ? await db + .select({ name: userTable.name }) + .from(userTable) + .where(eq(userTable.id, userId)) + .limit(1) + : []; + + const rendered = await renderEmail( + 'notification', + { + content: body.content, + locale: lang, + recipientName: recipient?.name, + title: body.title, + typeLabel: typeLabels[type][lang], + // A pure SMTP delivery test has no user behind it to unsubscribe. + unsubscribeUrl: userId + ? `${env.baseUrl}/unsubscribe?userId=${userId}&token=${generateUnsubscribeToken( + userId + )}` + : undefined, + }, + branding + ); + + await tp.send({ ...rendered, to }); logger.debug('Email sent to {to} for notification type {type}', { to, @@ -186,29 +149,25 @@ export async function sendEmail( } /** - * Render a notification template to HTML without sending it. Used by the + * Render a notification email to HTML without sending it. Used by the * dev-only preview endpoint so template styling can be reviewed. */ -export async function renderEmail( +export async function renderPreviewEmail( type: NotificationType, locale: string, - templateData: Record + body: { content: string; title: string } ): Promise { - const log = localizedFor(locale); - const sampleName = log === localized.en ? 'John Doe' : 'Minta Felhasználó'; - const template = await loadEmailTemplate(); - return template({ - ...templateData, - autoMessage: log.autoMessage, - baseUrl: env.baseUrl, - greeting: buildGreeting( - (templateData.recipientName as string | undefined) ?? sampleName, - locale - ), - lang: log === localized.en ? 'en' : 'hu', - typeLabel: typeLabelFor(type, locale), - unsubscribeLabel: log.unsubscribeLabel, - unsubscribeUrl: '', - viewLabel: log.viewLabel, - }); + const lang = locale === 'en' ? 'en' : 'hu'; + const { html } = await renderEmail( + 'notification', + { + content: body.content, + locale: lang, + recipientName: lang === 'en' ? 'John Doe' : 'Minta Felhasználó', + title: body.title, + typeLabel: typeLabels[type][lang], + }, + branding + ); + return html; } diff --git a/apps/chronos/src/utils/notifications/providers/templates/body.hbs b/apps/chronos/src/utils/notifications/providers/templates/body.hbs deleted file mode 100644 index d748ca34..00000000 --- a/apps/chronos/src/utils/notifications/providers/templates/body.hbs +++ /dev/null @@ -1 +0,0 @@ -

    {{content}}

    diff --git a/apps/chronos/src/utils/notifications/providers/templates/layout.hbs b/apps/chronos/src/utils/notifications/providers/templates/layout.hbs deleted file mode 100644 index fb3cd31d..00000000 --- a/apps/chronos/src/utils/notifications/providers/templates/layout.hbs +++ /dev/null @@ -1,118 +0,0 @@ - - - - - - - - {{title}} - - - -
    -
    -
    - {{typeLabel}} -

    {{title}}

    -
    -
    -

    {{greeting}}

    - {{> content}} - {{viewLabel}} -
    - -
    -
    - - diff --git a/apps/chronos/src/utils/notifications/queue.ts b/apps/chronos/src/utils/notifications/queue.ts index d5146757..e52201b1 100644 --- a/apps/chronos/src/utils/notifications/queue.ts +++ b/apps/chronos/src/utils/notifications/queue.ts @@ -32,7 +32,6 @@ async function processJob(job: DeliveryJob): Promise { const emailPromise = sendEmail( job.email, - job.title, job.type, 'hu', { diff --git a/apps/chronos/tsconfig.json b/apps/chronos/tsconfig.json index 31f132f2..77653bc4 100644 --- a/apps/chronos/tsconfig.json +++ b/apps/chronos/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "@filcdev/tsconfig/node.json", "compilerOptions": { + "jsx": "react-jsx", "paths": { "@/*": ["./src/*"] } diff --git a/biome.jsonc b/biome.jsonc index 144442c3..e907d871 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -12,9 +12,6 @@ "!bun.lock" ] }, - // `noNodejsModules` is off globally (chronos scripts and entry code need - // `node:*`), but enforced for the browser-bound and server-free workspaces - // via the override below. "overrides": [ { "includes": [ diff --git a/bun.lock b/bun.lock index ed5a60b7..e34cc24c 100644 --- a/bun.lock +++ b/bun.lock @@ -18,6 +18,7 @@ "@better-auth/drizzle-adapter": "^1.7.7", "@filcdev/api": "workspace:*", "@filcdev/auth": "workspace:*", + "@filcdev/mail": "workspace:*", "@filcdev/timetable-import": "workspace:*", "@logtape/logtape": "^2.4.0", "@orpc/contract": "catalog:", @@ -33,20 +34,17 @@ "drizzle-zod": "^0.8.3", "fast-xml-parser": "catalog:", "firebase-admin": "^14.5.0", - "handlebars": "^4.7.10", "jose": "^6.2.12", - "nodemailer": "^10.0.15", "string-ts": "^2.3.1", "zod": "catalog:", }, "devDependencies": { + "@filcdev/tsconfig": "workspace:*", "@inquirer/prompts": "^8.7.3", "@logtape/pretty": "^2.4.0", "@openapi-spec/downgrader": "^0.3.0", - "@tsconfig/strictest": "catalog:", "@types/bun": "catalog:", "@types/cli-progress": "^3.11.6", - "@types/nodemailer": "^8.0.2", "cli-progress": "^3.12.0", "drizzle-kit": "^0.31.11", "pg": "^8.23.1", @@ -93,8 +91,8 @@ }, "devDependencies": { "@filcdev/api": "workspace:*", + "@filcdev/tsconfig": "workspace:*", "@tanstack/devtools-vite": "^0.8.5", - "@tsconfig/strictest": "catalog:", "@types/bun": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", @@ -124,8 +122,8 @@ }, "devDependencies": { "@filcdev/api": "workspace:*", + "@filcdev/tsconfig": "workspace:*", "@tanstack/router-plugin": "^1.168.42", - "@tsconfig/strictest": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", "@vitejs/plugin-react": "catalog:", @@ -143,7 +141,7 @@ "zod": "catalog:", }, "devDependencies": { - "@tsconfig/strictest": "catalog:", + "@filcdev/tsconfig": "workspace:*", "@types/bun": "catalog:", "typescript": "catalog:", }, @@ -156,7 +154,7 @@ "better-auth": "catalog:", }, "devDependencies": { - "@tsconfig/strictest": "catalog:", + "@filcdev/tsconfig": "workspace:*", "@types/bun": "catalog:", "@types/react": "catalog:", "typescript": "catalog:", @@ -165,6 +163,25 @@ "react": "catalog:", }, }, + "packages/mail": { + "name": "@filcdev/mail", + "version": "0.1.0", + "dependencies": { + "@react-email/components": "^1.0.12", + "@react-email/render": "^2.1.0", + "nodemailer": "^10.0.11", + "react": "catalog:", + "react-dom": "catalog:", + "zod": "catalog:", + }, + "devDependencies": { + "@filcdev/tsconfig": "workspace:*", + "@types/bun": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "typescript": "catalog:", + }, + }, "packages/navigator-3d": { "name": "@filcdev/navigator-3d", "version": "0.1.0", @@ -173,7 +190,7 @@ "three": "^0.186.1", }, "devDependencies": { - "@tsconfig/strictest": "catalog:", + "@filcdev/tsconfig": "workspace:*", "@types/react": "catalog:", "@types/three": "^0.186.0", "typescript": "catalog:", @@ -192,11 +209,15 @@ "zod": "catalog:", }, "devDependencies": { - "@tsconfig/strictest": "catalog:", + "@filcdev/tsconfig": "workspace:*", "@types/bun": "catalog:", "typescript": "catalog:", }, }, + "packages/tsconfig": { + "name": "@filcdev/tsconfig", + "version": "0.0.0", + }, "packages/ui": { "name": "@filcdev/ui", "version": "0.1.0", @@ -217,7 +238,7 @@ "tw-animate-css": "^1.4.0", }, "devDependencies": { - "@tsconfig/strictest": "catalog:", + "@filcdev/tsconfig": "workspace:*", "@types/react": "catalog:", "tailwindcss": "catalog:", "typescript": "catalog:", @@ -239,7 +260,6 @@ "@tanstack/react-form": "^1.33.5", "@tanstack/react-query": "^5.104.1", "@tanstack/react-router": "^1.170.41", - "@tsconfig/strictest": "^2.0.8", "@types/bun": "^1.4.2", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", @@ -444,10 +464,14 @@ "@filcdev/kiosk": ["@filcdev/kiosk@workspace:apps/kiosk"], + "@filcdev/mail": ["@filcdev/mail@workspace:packages/mail"], + "@filcdev/navigator-3d": ["@filcdev/navigator-3d@workspace:packages/navigator-3d"], "@filcdev/timetable-import": ["@filcdev/timetable-import@workspace:packages/timetable-import"], + "@filcdev/tsconfig": ["@filcdev/tsconfig@workspace:packages/tsconfig"], + "@filcdev/ui": ["@filcdev/ui@workspace:packages/ui"], "@firebase/app-check-interop-types": ["@firebase/app-check-interop-types@0.3.5", "", {}, "sha512-qId34pVZ2CXTmtu4ofW5leiI93DvnOvIcr/5GT7MZPw5WXAi6nZoU+g9cNRgl7K90UJSbK0cXxten4meYMPyZg=="], @@ -714,6 +738,48 @@ "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw=="], + "@react-email/body": ["@react-email/body@0.3.0", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-uGo0BOOzjbMUo3lu+BIDWayvn5o6Xyfmnlla5VGf05n8gHMvO1ll7U4FtzWe3hxMLwt53pmc4iE0M+B5slG+Ug=="], + + "@react-email/button": ["@react-email/button@0.2.1", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-qXyj7RZLE7POy9BMKSoqQ00tOXThjOZSUnI2Yu9i29IHngPlmrNayIWBoVKtElES7OWwypUcpiajwi1mUWx6/A=="], + + "@react-email/code-block": ["@react-email/code-block@0.2.1", "", { "dependencies": { "prismjs": "^1.30.0" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-M3B7JpVH4ytgn83/ujRR1k1DQHvTeABiDM61OvAbjLRPhC/5KLHU5KkzIbbuGIrjWwxAbL1kSQzU8MhLEtSxyw=="], + + "@react-email/code-inline": ["@react-email/code-inline@0.0.6", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jfhebvv3dVsp3OdPgKXnk8+e2pBiDVZejDOBFzBa/IblrAJ9cQDkN6rBD5IyEg8hTOxwbw3iaI/yZFmDmIguIA=="], + + "@react-email/column": ["@react-email/column@0.0.14", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-f+W+Bk2AjNO77zynE33rHuQhyqVICx4RYtGX9NKsGUg0wWjdGP0qAuIkhx9Rnmk4/hFMo1fUrtYNqca9fwJdHg=="], + + "@react-email/components": ["@react-email/components@1.0.12", "", { "dependencies": { "@react-email/body": "0.3.0", "@react-email/button": "0.2.1", "@react-email/code-block": "0.2.1", "@react-email/code-inline": "0.0.6", "@react-email/column": "0.0.14", "@react-email/container": "0.0.16", "@react-email/font": "0.0.10", "@react-email/head": "0.0.13", "@react-email/heading": "0.0.16", "@react-email/hr": "0.0.12", "@react-email/html": "0.0.12", "@react-email/img": "0.0.12", "@react-email/link": "0.0.13", "@react-email/markdown": "0.0.18", "@react-email/preview": "0.0.14", "@react-email/render": "2.0.6", "@react-email/row": "0.0.13", "@react-email/section": "0.0.17", "@react-email/tailwind": "2.0.7", "@react-email/text": "0.1.6" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-tH18JhPDWgE+3jnYkzyB6ZrZdfNnEsFe4PwmuXmlOw4NGIysP8wPY5aXZg++pTG9qUabXg1nzX/FGHGkObH8xQ=="], + + "@react-email/container": ["@react-email/container@0.0.16", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-QWBB56RkkU0AJ9h+qy33gfT5iuZknPC7Un/IjZv9B0QmMIK+WWacc0cH6y2SV5Cv/b99hU94fjEMOOO4enpkbQ=="], + + "@react-email/font": ["@react-email/font@0.0.10", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-0urVSgCmQIfx5r7Xc586miBnQUVnGp3OTYUm8m5pwtQRdTRO5XrTtEfNJ3JhYhSOruV0nD8fd+dXtKXobum6tA=="], + + "@react-email/head": ["@react-email/head@0.0.13", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-AJg6le/08Gz4tm+6MtKXqtNNyKHzmooOCdmtqmWxD7FxoAdU1eVcizhtQ0gcnVaY6ethEyE/hnEzQxt1zu5Kog=="], + + "@react-email/heading": ["@react-email/heading@0.0.16", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jmsKnQm1ykpBzw4hCYHwBkt5pW2jScXffPeEH5ZRF5tZeF5b1pvlFTO9han7C0pCkZYo1kEvWiRtx69yfCIwuw=="], + + "@react-email/hr": ["@react-email/hr@0.0.12", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-TwmOmBDibavUQpXBxpmZYi2Iks/yeZOzFYh+di9EltMSnEabH8dMZXrl+pxNXzCgZ2XE8HY7VmUL65Lenfu5PA=="], + + "@react-email/html": ["@react-email/html@0.0.12", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-KTShZesan+UsreU7PDUV90afrZwU5TLwYlALuCSU0OT+/U8lULNNbAUekg+tGwCnOfIKYtpDPKkAMRdYlqUznw=="], + + "@react-email/img": ["@react-email/img@0.0.12", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-sRCpEARNVTf3FQhZOC+JTvu5r6ubiYWkT0ucYXg8ctkyi4G8QG+jgYPiNUqVeTLA2STOfmPM/nrk1nb84y6CPQ=="], + + "@react-email/link": ["@react-email/link@0.0.13", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-lkWc/NjOcefRZMkQoSDDbuKBEBDES9aXnFEOuPH845wD3TxPwh+QTf0fStuzjoRLUZWpHnio4z7qGGRYusn/sw=="], + + "@react-email/markdown": ["@react-email/markdown@0.0.18", "", { "dependencies": { "marked": "^15.0.12" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-gSuYK5fsMbGk87jDebqQ6fa2fKcWlkf2Dkva8kMONqLgGCq8/0d+ZQYMEJsdidIeBo3kmsnHZPrwdFB4HgjUXg=="], + + "@react-email/preview": ["@react-email/preview@0.0.14", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aYK8q0IPkBXyMsbpMXgxazwHxYJxTrXrV95GFuu2HbEiIToMwSyUgb8HDFYwPqqfV03/jbwqlsXmFxsOd+VNaw=="], + + "@react-email/render": ["@react-email/render@2.1.0", "", { "dependencies": { "entities": "^4.5.0", "html-to-text": "^9.0.5", "html5parser": "^3.0.0", "prettier": "^3.5.3" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-F+zE3O6d6sW6Aj2UjvZAA17R7tJKM7kcq2mgV6k4HCT8jeLLFaVP2txMtH1lgqYFRMZ0Gxsd37q2PRyiXLXXxA=="], + + "@react-email/row": ["@react-email/row@0.0.13", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-bYnOac40vIKCId7IkwuLAAsa3fKfSfqCvv6epJKmPE0JBuu5qI4FHFCl9o9dVpIIS08s/ub+Y/txoMt0dYziGw=="], + + "@react-email/section": ["@react-email/section@0.0.17", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-qNl65ye3W0Rd5udhdORzTV9ezjb+GFqQQSae03NDzXtmJq6sqVXNWNiVolAjvJNypim+zGXmv6J9TcV5aNtE/w=="], + + "@react-email/tailwind": ["@react-email/tailwind@2.0.7", "", { "dependencies": { "tailwindcss": "^4.1.18" }, "peerDependencies": { "@react-email/body": ">=0", "@react-email/button": ">=0", "@react-email/code-block": ">=0", "@react-email/code-inline": ">=0", "@react-email/container": ">=0", "@react-email/heading": ">=0", "@react-email/hr": ">=0", "@react-email/img": ">=0", "@react-email/link": ">=0", "@react-email/preview": ">=0", "@react-email/text": ">=0", "react": "^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@react-email/body", "@react-email/button", "@react-email/code-block", "@react-email/code-inline", "@react-email/container", "@react-email/heading", "@react-email/hr", "@react-email/img", "@react-email/link", "@react-email/preview"] }, "sha512-kGw80weVFXikcnCXbigTGXGWQ0MRCSYNCudcdkWxebkWYd0FG6/NPoN3V1p/u68/4+NxZwYPVi2fhnp0x23HdA=="], + + "@react-email/text": ["@react-email/text@0.1.6", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw=="], + "@react-pdf/fns": ["@react-pdf/fns@3.1.3", "", {}, "sha512-0I7pApDr1/RLAKbizuLy/IHTEa93LSPy/bEwYniboC3Xqnp6Od8xFJKbKEzGw2wh/5zKFFwl00g4t9RwgIMc3w=="], "@react-pdf/font": ["@react-pdf/font@4.1.2", "", { "dependencies": { "@react-pdf/types": "^2.14.0", "fontkit": "^2.0.2", "is-url": "^1.2.4", "pdfkit": "0.20.1" } }, "sha512-RT/jiGWIRjIC9c4S9NiqhEyfuA6YL+DtWL3Rm+k+zQhfeHYvHwGzFxr7ZJzThIMrT8sIQy+mpvFvfYyitEei/Q=="], @@ -778,6 +844,8 @@ "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="], + "@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="], + "@sentry/browser": ["@sentry/browser@11.5.0", "", { "dependencies": { "@sentry/browser-utils": "11.5.0", "@sentry/conventions": "^0.26.0", "@sentry/core": "11.5.0", "@sentry/feedback": "11.5.0", "@sentry/replay": "11.5.0", "@sentry/replay-canvas": "11.5.0" } }, "sha512-jwky2cRbwdQG4JpU3spKX9h2gc8C57AFnrQrGJc3AC+jCgwpfso/I0MI6Am3f552gVCR9kfuT6ozv1YliV+FoQ=="], "@sentry/browser-utils": ["@sentry/browser-utils@11.5.0", "", { "dependencies": { "@sentry/conventions": "^0.26.0", "@sentry/core": "11.5.0", "web-vitals": "^6.2.3" } }, "sha512-R2rro9Hc4jtOprXYwCjyS6WqmdXX/5Rzya6C7X5HWdcW5/osW1ICB4Yk5XndrgVDUeEJ6aqo8mKvmTWJ759ATQ=="], @@ -946,8 +1014,6 @@ "@ts-morph/common": ["@ts-morph/common@0.27.0", "", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="], - "@tsconfig/strictest": ["@tsconfig/strictest@2.0.8", "", {}, "sha512-XnQ7vNz5HRN0r88GYf1J9JJjqtZPiHt2woGJOo2dYqyHGGcd6OLGqSlBB6p1j9mpzja6Oe5BoPqWmeDx6X9rLw=="], - "@turbo/darwin-64": ["@turbo/darwin-64@2.11.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z2LFOxwbiJueD1miByXJS/BrW21vv6YaQXZustty5jEX4vQYw6zyh101Sz1QO7WG0mqBzKagZ/DoAeeujI0/Sw=="], "@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.11.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Vhv8ybgIflosNDSSRD9fNXHlCFA60dsGyTqQmc7l28SEX5jOaweCCD4BBYNd8/p0WGkw3e7tZbfA9w6FsujGvw=="], @@ -994,8 +1060,6 @@ "@types/node": ["@types/node@26.3.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw=="], - "@types/nodemailer": ["@types/nodemailer@8.0.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-c7M5ox8p0nEOfbJ2E9Qcmt8/QCu/VzsiAhzBiZbvky2PhKZDHpKB3sQHBM5MEZNkCfBOaS6S9//AGPRTNB7IaA=="], - "@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="], "@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="], @@ -1242,6 +1306,14 @@ "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], + + "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], + + "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], + + "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], + "dot-prop": ["dot-prop@6.0.1", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA=="], "dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], @@ -1276,6 +1348,8 @@ "enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="], + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], @@ -1420,8 +1494,6 @@ "h3-v2": ["h3@2.0.1-rc.20", "", { "dependencies": { "rou3": "^0.8.1", "srvx": "^0.11.13" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"], "bin": { "h3": "bin/h3.mjs" } }, "sha512-28ljodXuUp0fZovdiSRq4G9OgrxCztrJe5VdYzXAB7ueRvI7pIUqLU14Xi3XqdYJ/khXjfpUOOD2EQa6CmBgsg=="], - "handlebars": ["handlebars@4.7.10", "", { "dependencies": { "minimist": "^1.2.8", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-P5VJMVM7qgBn6vjXMw8WG9uVI+ncf2pi72j4de4yz5ZULLj2RGqLYaKOYGsgyrViQ0tePOVlN1tDCCXXtFqXKg=="], - "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], @@ -1440,6 +1512,12 @@ "html-parse-stringify": ["html-parse-stringify@4.0.1", "", {}, "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw=="], + "html-to-text": ["html-to-text@9.0.5", "", { "dependencies": { "@selderee/plugin-htmlparser2": "^0.11.0", "deepmerge": "^4.3.1", "dom-serializer": "^2.0.0", "htmlparser2": "^8.0.2", "selderee": "^0.11.0" } }, "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg=="], + + "html5parser": ["html5parser@3.0.0", "", {}, "sha512-iNpSopa+4YHX50UOk825tBy7MghmXHo/ZpLskBYN0kAr1xhH8GlIMk5bLRXcZlfP3AnLUcSuFMu8C4MdOUxA8A=="], + + "htmlparser2": ["htmlparser2@8.0.2", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "entities": "^4.4.0" } }, "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA=="], + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], "http-parser-js": ["http-parser-js@0.5.10", "", {}, "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA=="], @@ -1548,6 +1626,8 @@ "launch-editor": ["launch-editor@2.14.1", "", { "dependencies": { "picocolors": "^1.1.1", "shell-quote": "^1.8.4" } }, "sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA=="], + "leac": ["leac@0.6.0", "", {}, "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg=="], + "lefthook": ["lefthook@2.2.1", "", { "optionalDependencies": { "lefthook-darwin-arm64": "2.2.1", "lefthook-darwin-x64": "2.2.1", "lefthook-freebsd-arm64": "2.2.1", "lefthook-freebsd-x64": "2.2.1", "lefthook-linux-arm64": "2.2.1", "lefthook-linux-x64": "2.2.1", "lefthook-openbsd-arm64": "2.2.1", "lefthook-openbsd-x64": "2.2.1", "lefthook-windows-arm64": "2.2.1", "lefthook-windows-x64": "2.2.1" }, "bin": { "lefthook": "bin/index.js" } }, "sha512-aKEwhQzGbLMkZu5vrFYKJzLvLVblWATYITmN7Ak4qRCDPCoFJhmeJZFuYf3nTXuqG9xTBE+J8xaAEPL2tpuWyw=="], "lefthook-darwin-arm64": ["lefthook-darwin-arm64@2.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-SyuDBT5HqNQfi607vMHrYJu45bRN7JoIFdyxLjEPVe34UnZJCesmeYBODEYTyCfRZjPOaTbton4Padu+qvqfCQ=="], @@ -1632,6 +1712,8 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "media-engine": ["media-engine@2.0.0", "", {}, "sha512-FqNmlXKYrp5d3g7xEOMJb+6gXZE0fTssdyIQqYR4LbkifbNbS0hDylhNDOh8r6tCgLboHd8+mwgH2njgSYDpnQ=="], @@ -1680,8 +1762,6 @@ "negotiator": ["negotiator@1.1.0", "", { "dependencies": { "content-type": "^2.1.0" } }, "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg=="], - "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], - "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], @@ -1732,6 +1812,8 @@ "parse-svg-path": ["parse-svg-path@0.1.2", "", {}, "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ=="], + "parseley": ["parseley@0.12.1", "", { "dependencies": { "leac": "^0.6.0", "peberminta": "^0.9.0" } }, "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw=="], + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], @@ -1750,6 +1832,8 @@ "pdfkit": ["pdfkit@0.20.1", "", { "dependencies": { "@noble/ciphers": "^1.3.0", "@noble/hashes": "^1.8.0", "fflate": "^0.8.3", "fontkit": "^2.0.4", "linebreak": "^1.1.0", "png-js": "^2.0.0" } }, "sha512-1rRXK6x5o8I/3dBrBzXfxibpHpkfCnIA7EBAES7pEpGFc/65inMLlA8SalGWpJfal7BGekxeLf6A30IOpQpc5Q=="], + "peberminta": ["peberminta@0.9.0", "", {}, "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ=="], + "pg": ["pg@8.23.1", "", { "dependencies": { "pg-connection-string": "^2.14.1", "pg-pool": "^3.14.0", "pg-protocol": "^1.16.1", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.4.1" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ=="], "pg-cloudflare": ["pg-cloudflare@1.4.1", "", {}, "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ=="], @@ -1796,6 +1880,8 @@ "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], + "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], + "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], @@ -1892,6 +1978,8 @@ "scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="], + "selderee": ["selderee@0.11.0", "", { "dependencies": { "parseley": "^0.12.0" } }, "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA=="], + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], @@ -2026,8 +2114,6 @@ "ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="], - "uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="], - "undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], @@ -2086,8 +2172,6 @@ "wildcard-match": ["wildcard-match@5.1.4", "", {}, "sha512-wldeCaczs8XXq7hj+5d/F38JE2r7EXgb6WQDM84RVwxy81T/sxB5e9+uZLK9Q9oNz1mlvjut+QtvgaOQFPVq/g=="], - "wordwrap": ["wordwrap@1.0.0", "", {}, "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="], - "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], @@ -2154,6 +2238,8 @@ "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + "@react-email/components/@react-email/render": ["@react-email/render@2.0.6", "", { "dependencies": { "html-to-text": "^9.0.5", "prettier": "^3.5.3" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-xOzaYkH3jLZKqN5MqrTXYnmqBYUnZSVbkxdb5PGGmDcK6sKDVMliaDiSwfXajRC9JtSHTcGc2tmGLHWuCgVpog=="], + "@react-pdf/reconciler/scheduler": ["scheduler@0.25.0-rc-603e6108-20241029", "", {}, "sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA=="], "@reduxjs/toolkit/reselect": ["reselect@5.3.0", "", {}, "sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg=="], diff --git a/packages/mail/package.json b/packages/mail/package.json new file mode 100644 index 00000000..04ce60bf --- /dev/null +++ b/packages/mail/package.json @@ -0,0 +1,28 @@ +{ + "name": "@filcdev/mail", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "test": "bun test", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@react-email/components": "^1.0.12", + "@react-email/render": "^2.1.0", + "nodemailer": "^10.0.11", + "react": "catalog:", + "react-dom": "catalog:", + "zod": "catalog:" + }, + "devDependencies": { + "@types/bun": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@filcdev/tsconfig": "workspace:*", + "typescript": "catalog:" + } +} diff --git a/packages/mail/src/index.ts b/packages/mail/src/index.ts new file mode 100644 index 00000000..e1392f60 --- /dev/null +++ b/packages/mail/src/index.ts @@ -0,0 +1,154 @@ +import { createTransport } from 'nodemailer'; +import { z } from 'zod'; +import { renderNotification } from './templates/notification'; +import type { + Branding, + NotificationEmailData, + RenderedEmail, +} from './templates/types'; + +export type { + Branding, + Locale, + NotificationEmailData, + RenderedEmail, +} from './templates/types'; + +export type OutboundMessage = { + html: string; + subject: string; + text: string; + to: string; +}; + +export type AccessToken = { + /** Unix ms when the token stops being valid; omitted renews only after it is rejected. */ + expires?: number; + token: string; +}; + +export type EntraCredentials = { + clientId: string; + clientSecret: string; + tenantId: string; +}; + +export type SmtpAuth = + | { pass?: string; type: 'basic'; user: string } + /** Microsoft 365 / Outlook.com: SASL XOAUTH2 with an Entra-minted bearer token. */ + | { entra: EntraCredentials; type: 'oauth2'; user: string }; + +export type SmtpConfig = { + auth?: SmtpAuth; + from: string; + host: string; + port: number; + secure: boolean; +}; + +export type MailTransport = { + send: (message: OutboundMessage) => Promise; +}; + +export const createMailTransport = (config: SmtpConfig): MailTransport => { + const { auth } = config; + const transport = createTransport({ + auth: + auth?.type === 'oauth2' + ? { + provisionCallback: (_user, _renew, callback) => { + fetchEntraAccessToken(auth.entra).then( + ({ expires, token }) => callback(null, token, expires), + (error: unknown) => + callback( + error instanceof Error ? error : new Error(String(error)) + ) + ); + }, + type: 'OAuth2', + user: auth.user, + } + : auth && { pass: auth.pass, user: auth.user }, + host: config.host, + port: config.port, + secure: config.secure, + }); + return { + send: async (message: OutboundMessage): Promise => { + await transport.sendMail({ + from: config.from, + html: message.html, + subject: message.subject, + text: message.text, + to: message.to, + }); + }, + }; +}; + +const MICROSOFT_AUTHORITY = 'https://login.microsoftonline.com'; +/** The fixed Exchange Online scope an app-only token must be minted for. */ +const OUTLOOK_SCOPE = 'https://outlook.office365.com/.default'; + +const entraTokenSchema = z.object({ + access_token: z.string().optional(), + error: z.string().optional(), + expires_in: z.number().optional(), +}); + +/** + * Mints a bearer token for SASL XOAUTH2 via the Entra client-credentials grant. + * `SMTP.SendAsApp` (Office 365 Exchange Online, application permission) plus a + * service principal registered in Exchange and granted on the mailbox are what + * make the resulting token acceptable to Exchange Online. + */ +export const fetchEntraAccessToken = async ( + credentials: EntraCredentials +): Promise => { + const response = await fetch( + `${MICROSOFT_AUTHORITY}/${credentials.tenantId}/oauth2/v2.0/token`, + { + body: new URLSearchParams({ + client_id: credentials.clientId, + client_secret: credentials.clientSecret, + grant_type: 'client_credentials', + scope: OUTLOOK_SCOPE, + }), + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + method: 'POST', + } + ); + const payload = entraTokenSchema.parse(await response.json()); + if (response.ok && payload.access_token) { + return { + expires: payload.expires_in + ? Date.now() + payload.expires_in * 1000 + : undefined, + token: payload.access_token, + }; + } + throw new Error( + `Entra token request failed (${response.status}): ${payload.error ?? response.statusText}` + ); +}; + +export type TemplateData = { + notification: NotificationEmailData; +}; + +export type TemplateName = keyof TemplateData; + +type TemplateRenderer = ( + data: TemplateData[Name], + branding: Branding +) => Promise; + +const renderers: { [Name in TemplateName]: TemplateRenderer } = { + notification: renderNotification, +}; + +export const renderEmail = ( + name: Name, + data: TemplateData[Name], + branding: Branding +): Promise => renderers[name](data, branding); diff --git a/packages/mail/src/templates/email-layout.tsx b/packages/mail/src/templates/email-layout.tsx new file mode 100644 index 00000000..d41c2621 --- /dev/null +++ b/packages/mail/src/templates/email-layout.tsx @@ -0,0 +1,73 @@ +import { + Body, + Container, + Head, + Heading, + Html, + Link, + Preview, + Section, + Text, +} from '@react-email/components'; +import type { ReactNode } from 'react'; +import { emailCopy } from './i18n'; +import { + bodyContentStyle, + bodyStyle, + cardStyle, + footerLinkStyle, + footerStyle, + footerTextStyle, + headerStyle, + titleStyle, + typePillStyle, +} from './styles'; +import type { Locale } from './types'; + +export type EmailLayoutProps = { + children: ReactNode; + lang: Locale; + preview: string; + title: string; + typeLabel: string; + unsubscribeUrl?: string; +}; + +export const EmailLayout = ({ + children, + lang, + preview, + title, + typeLabel, + unsubscribeUrl, +}: EmailLayoutProps) => { + return ( + + + + + {title} + + {preview} + + +
    + {typeLabel} + {title} +
    +
    {children}
    +
    + {emailCopy[lang].autoMessage} + {unsubscribeUrl ? ( + + + {emailCopy[lang].unsubscribeLabel} + + + ) : undefined} +
    +
    + + + ); +}; diff --git a/packages/mail/src/templates/i18n.ts b/packages/mail/src/templates/i18n.ts new file mode 100644 index 00000000..895c0f2b --- /dev/null +++ b/packages/mail/src/templates/i18n.ts @@ -0,0 +1,28 @@ +import type { Locale } from './types'; + +export const emailCopy = { + en: { + autoMessage: 'This is an automated message from Filc.', + greeting: 'Dear Filc user,', + greetingNamed: 'Dear {name},', + unsubscribeLabel: 'Unsubscribe from notifications', + viewLabel: 'View in Filc', + }, + hu: { + autoMessage: 'Ez az email a Filc rendszer automatikus üzenete.', + greeting: 'Kedves Filc felhasználó!', + greetingNamed: 'Kedves {name}!', + unsubscribeLabel: 'Leiratkozás az értesítésekről', + viewLabel: 'Megtekintés a Filcben', + }, +} as const; + +export const greetingFor = ( + locale: Locale, + recipientName?: string | null +): string => { + const name = recipientName?.trim(); + return name + ? emailCopy[locale].greetingNamed.replace('{name}', name) + : emailCopy[locale].greeting; +}; diff --git a/packages/mail/src/templates/notification.tsx b/packages/mail/src/templates/notification.tsx new file mode 100644 index 00000000..82ec2ed8 --- /dev/null +++ b/packages/mail/src/templates/notification.tsx @@ -0,0 +1,30 @@ +import { Button, Text } from '@react-email/components'; +import { EmailLayout } from './email-layout'; +import { emailCopy, greetingFor } from './i18n'; +import { renderEmailElement } from './render'; +import { buttonStyle, greetingStyle, textStyle } from './styles'; +import type { Branding, NotificationEmailData, RenderedEmail } from './types'; + +export const renderNotification = ( + data: NotificationEmailData, + branding: Branding +): Promise => { + const element = ( + + + {greetingFor(data.locale, data.recipientName)} + + {data.content} + + + ); + return renderEmailElement(element, data.title); +}; diff --git a/packages/mail/src/templates/render.ts b/packages/mail/src/templates/render.ts new file mode 100644 index 00000000..0b66a4d4 --- /dev/null +++ b/packages/mail/src/templates/render.ts @@ -0,0 +1,14 @@ +import { render } from '@react-email/render'; +import type { ReactElement } from 'react'; +import type { RenderedEmail } from './types'; + +export const renderEmailElement = async ( + element: ReactElement, + subject: string +): Promise => { + const [html, text] = await Promise.all([ + render(element), + render(element, { plainText: true }), + ]); + return { html, subject, text }; +}; diff --git a/packages/mail/src/templates/styles.ts b/packages/mail/src/templates/styles.ts new file mode 100644 index 00000000..f2f9bce3 --- /dev/null +++ b/packages/mail/src/templates/styles.ts @@ -0,0 +1,104 @@ +import type { CSSProperties } from 'react'; + +const FONT_STACK = + '"Outfit", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'; + +export const bodyStyle: CSSProperties = { + backgroundColor: '#f4f5f7', + color: '#18181b', + fontFamily: FONT_STACK, + lineHeight: 1.6, + margin: 0, + padding: '24px 12px', + WebkitFontSmoothing: 'antialiased', +}; + +export const cardStyle: CSSProperties = { + backgroundColor: '#ffffff', + borderRadius: '18px', + boxShadow: + '0 1px 3px rgba(24, 24, 27, 0.08), 0 8px 24px rgba(24, 24, 27, 0.06)', + margin: '0 auto', + maxWidth: '600px', + overflow: 'hidden', + width: '100%', +}; + +export const headerStyle: CSSProperties = { + backgroundColor: '#0d9488', + color: '#ffffff', + padding: '28px', +}; + +export const typePillStyle: CSSProperties = { + backgroundColor: 'rgba(255, 255, 255, 0.16)', + borderRadius: '999px', + color: '#ffffff', + display: 'inline-block', + fontSize: '11px', + fontWeight: 600, + letterSpacing: '0.02em', + lineHeight: 1.6, + margin: '0 0 10px', + padding: '4px 12px', +}; + +export const titleStyle: CSSProperties = { + color: '#ffffff', + fontSize: '21px', + fontWeight: 600, + letterSpacing: '-0.01em', + margin: 0, +}; + +export const bodyContentStyle: CSSProperties = { + color: '#3f3f46', + fontSize: '15px', + lineHeight: 1.6, + padding: '26px 28px', +}; + +export const greetingStyle: CSSProperties = { + color: '#18181b', + fontWeight: 600, + lineHeight: 1.6, + margin: '0 0 12px', +}; + +export const textStyle: CSSProperties = { + lineHeight: 1.6, + margin: '0 0 16px', +}; + +export const buttonStyle: CSSProperties = { + backgroundColor: '#0d9488', + borderRadius: '26px', + color: '#ffffff', + display: 'inline-block', + fontSize: '14px', + fontWeight: 600, + lineHeight: 1.6, + marginTop: '8px', + padding: '10px 24px', + textDecoration: 'none', +}; + +export const footerStyle: CSSProperties = { + backgroundColor: '#fafafa', + borderTop: '1px solid #f0f0f2', + color: '#71717a', + fontSize: '12px', + lineHeight: 1.6, + padding: '18px 28px', +}; + +export const footerTextStyle: CSSProperties = { + fontSize: '12px', + lineHeight: 1.6, + margin: '0 0 6px', +}; + +export const footerLinkStyle: CSSProperties = { + color: '#0d9488', + textDecoration: 'none', +}; diff --git a/packages/mail/src/templates/types.ts b/packages/mail/src/templates/types.ts new file mode 100644 index 00000000..1c05ca85 --- /dev/null +++ b/packages/mail/src/templates/types.ts @@ -0,0 +1,23 @@ +export type Locale = 'en' | 'hu'; + +export type Branding = { + appName: string; + appUrl: string; +}; + +export type RenderedEmail = { + html: string; + subject: string; + text: string; +}; + +export type NotificationEmailData = { + content: string; + locale: Locale; + recipientName?: string | null; + title: string; + /** Localized notification-category label; the caller owns the type → label map. */ + typeLabel: string; + /** Absent only in the dev-only styling preview. */ + unsubscribeUrl?: string; +}; diff --git a/packages/mail/tsconfig.json b/packages/mail/tsconfig.json new file mode 100644 index 00000000..7c3c72c4 --- /dev/null +++ b/packages/mail/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "@filcdev/tsconfig/react-library.json", + "include": ["src"] +} From bc6352754ccbf9175eead2ab706c58454a2e2ae6 Mon Sep 17 00:00:00 2001 From: Tamas Vince Date: Fri, 16 Oct 2026 10:16:21 +0200 Subject: [PATCH 29/32] feat: full tracing and get rid of logging holes --- apps/chronos/.env.example | 13 +- apps/chronos/src/_types/globals.ts | 6 + apps/chronos/src/index.ts | 36 ++- apps/chronos/src/middleware/auth.ts | 14 +- apps/chronos/src/modules/kiosk/departures.ts | 9 +- apps/chronos/src/modules/kiosk/petrik-news.ts | 8 +- .../src/modules/kiosk/utils/petrik-news.ts | 8 +- apps/chronos/src/modules/wifi/self.ts | 8 +- .../src/modules/wifi/utils/controller.ts | 14 ++ apps/chronos/src/modules/wifi/utils/radius.ts | 11 +- apps/chronos/src/modules/wifi/utils/sync.ts | 9 + .../src/modules/wifi/utils/unifi-api.ts | 215 +++++++++++++++--- .../modules/wifi/utils/unifi-controller.ts | 9 +- apps/chronos/src/utils/entra-id-token.ts | 10 +- apps/iris/src/client.tsx | 10 +- docs/architecture.md | 10 + 16 files changed, 342 insertions(+), 48 deletions(-) diff --git a/apps/chronos/.env.example b/apps/chronos/.env.example index a2a905d2..8dcd18e7 100644 --- a/apps/chronos/.env.example +++ b/apps/chronos/.env.example @@ -36,8 +36,19 @@ CHRONOS_ENTRA_CLIENT_SECRET=FILL_ME_IN # HTTP port the API binds (default: 3001) # CHRONOS_PORT=3001 +# -------------------------------------------------- +# | Observability | +# -------------------------------------------------- + +# trace | debug | info | warning | error (default: info). Every API request is +# logged at INFO with url, status, duration, ip, ua and user; trace adds the +# route table, one line per request at development level and every SQL +# statement when combined with CHRONOS_DRIZZLE_DEBUG. +CHRONOS_LOG_LEVEL=debug + # development | production. Any non-production value makes error responses -# carry the underlying message and cause (default: development) +# carry the underlying message and cause, and picks the pretty formatter over +# the production JSON lines (default: development) # CHRONOS_MODE=development # Log every SQL statement at trace level (default: false) diff --git a/apps/chronos/src/_types/globals.ts b/apps/chronos/src/_types/globals.ts index 2b406187..0169cb64 100644 --- a/apps/chronos/src/_types/globals.ts +++ b/apps/chronos/src/_types/globals.ts @@ -7,6 +7,12 @@ import type { auth } from '#utils/authentication'; export type ChronosContext = { anonymousId: string | null; clientIp: string; + /** + * Minted per request in `src/index.ts` and echoed back on the response as + * `x-request-id`, so a client (or a bug report) can name the exact request + * it is talking about. Every log line the transport emits carries it. + */ + requestId: string; reqHeaders: Headers; /** Injected by `ResponseHeadersPlugin`; a procedure can add to the response. */ resHeaders?: Headers; diff --git a/apps/chronos/src/index.ts b/apps/chronos/src/index.ts index fd5a1c62..1e85921b 100644 --- a/apps/chronos/src/index.ts +++ b/apps/chronos/src/index.ts @@ -91,8 +91,16 @@ type ClientInterceptor = NonNullable< * Turns anything a handler throws that is not already an `ORPCError` into a * logged `INTERNAL` error: the message is the raw one in development and the * generic one in production, so internal failures never leak their details. + * + * The log line carries the request id, the client address and the procedure + * that failed, because a bare stack is not enough to tell *which* request + * produced it once more than one is in flight. */ -const errorBoundaryInterceptor: ClientInterceptor = async ({ next }) => { +const errorBoundaryInterceptor: ClientInterceptor = async ({ + context, + next, + path, +}) => { try { return await next(); } catch (error) { @@ -102,6 +110,12 @@ const errorBoundaryInterceptor: ClientInterceptor = async ({ next }) => { const err = error instanceof Error ? error : new Error(String(error)); logger.error('UNCAUGHT API error occurred:', { + context: { + clientIp: context.clientIp, + procedure: path.join('/'), + requestId: context.requestId, + userId: context.user?.id ?? context.session?.userId ?? null, + }, message: err.message, stack: err.stack, }); @@ -118,7 +132,11 @@ type AdapterInterceptor = NonNullable< FetchHandlerOptions['adapterInterceptors'] >[number]; -/** Request log: trace line in development, structured line otherwise. */ +/** + * Request log: trace line in development, structured line otherwise. Carries + * the request id so it can be joined to the error boundary's line and to a + * client's `x-request-id` response header. + */ const timingInterceptor: AdapterInterceptor = async (options) => { const start = Date.now(); const result = await options.next(); @@ -128,13 +146,14 @@ const timingInterceptor: AdapterInterceptor = async (options) => { const { method } = options.request; const { url } = options.request; const { - context: { user, clientIp }, + context: { clientIp, requestId, user }, } = options; if (isDevelopment) { logger.trace(`${method} ${url} - ${ms}ms`, { duration: ms, method, + requestId, status, url, user: user ? { email: user.email, id: user.id } : null, @@ -147,6 +166,7 @@ const timingInterceptor: AdapterInterceptor = async (options) => { duration: ms, ip: clientIp, method, + requestId, status, ua: options.request.headers.get('user-agent') ?? 'unknown', url, @@ -203,6 +223,9 @@ const buildContext = async ( ? (request.headers.get(env.realIpHeader) ?? '') : (srv.requestIP(request)?.address ?? 'unknown'), reqHeaders: request.headers, + // Echoed on the response as `x-request-id`, so a client can name the exact + // request it is reporting and the line can be found in the log stream. + requestId: request.headers.get('x-request-id') ?? crypto.randomUUID(), }); export const server = Bun.serve({ @@ -226,16 +249,21 @@ export const server = Bun.serve({ return withSecurityHeaders(await handleUnsubscribe(request)); } + const requestId = + request.headers.get('x-request-id') ?? crypto.randomUUID(); const context = await buildContext(request, srv); const { matched, response } = url.pathname.startsWith('/api/rpc/') ? await rpcHandler.handle(request, { context, prefix: '/api/rpc' }) : await openApiHandler.handle(request, { context, prefix: '/api' }); - return withSecurityHeaders( + const final = withSecurityHeaders( matched && response ? response : new Response('Not found', { status: 404 }) ); + final.headers.set('x-request-id', requestId); + + return final; }, port: env.port, websocket: deviceSocketHandlers, diff --git a/apps/chronos/src/middleware/auth.ts b/apps/chronos/src/middleware/auth.ts index cba06235..60d0660f 100644 --- a/apps/chronos/src/middleware/auth.ts +++ b/apps/chronos/src/middleware/auth.ts @@ -1,4 +1,5 @@ import { createHmac, timingSafeEqual } from 'node:crypto'; +import { getLogger } from '@logtape/logtape'; import type { ChronosContext } from '#_types/globals'; import { base } from '#orpc'; import { auth } from '#utils/authentication'; @@ -6,6 +7,8 @@ import { rbac, userHasPermission } from '#utils/authorization'; import { env } from '#utils/environment'; import { setSentryUser } from '#utils/telemetry'; +const logger = getLogger(['chronos', 'auth']); + const SIGNATURE_LENGTH = 64; // SHA-256 hex digest /** @@ -73,7 +76,16 @@ export async function resolveCaller( // request carrying a stale key into a 500. A key that fails to resolve is // simply not a caller, so the request continues as anonymous and the route's // own guard decides. - const session = await auth.api.getSession({ headers }).catch(() => null); + // + // The failure is logged rather than discarded: a database outage throws here + // too, and "every request became anonymous" is otherwise indistinguishable + // from a mass logout. + const session = await auth.api.getSession({ headers }).catch((error) => { + logger.debug('Session resolution failed, continuing as anonymous', { + error, + }); + return null; + }); if (session) { setSentryUser(session.session, session.user); diff --git a/apps/chronos/src/modules/kiosk/departures.ts b/apps/chronos/src/modules/kiosk/departures.ts index 68f0b7cd..f1b6a405 100644 --- a/apps/chronos/src/modules/kiosk/departures.ts +++ b/apps/chronos/src/modules/kiosk/departures.ts @@ -1,7 +1,10 @@ +import { getLogger } from '@logtape/logtape'; import { base } from '#orpc'; import { env } from '#utils/environment'; import { badGateway } from '#utils/http'; +const logger = getLogger(['chronos', 'kiosk', 'departures']); + const BKK_URL = 'https://futar.bkk.hu/api/query/v1/ws/otp/api/where/arrivals-and-departures-for-stop'; const DEPARTURES_CACHE_TTL_MS = 30_000; @@ -119,7 +122,11 @@ async function fetchDeparturesForStop( return routeFilter ? filteredDeparture(stopTimes, body.data?.references, routeFilter) : nextDeparture(stopTimes, body.data?.references); - } catch { + } catch (error) { + // A timeout or a network error is not "stop has no departures": the kiosk + // renders nothing either way, so the reason must be recoverable from the + // log stream rather than only from the empty screen. + logger.debug('BKK departures lookup failed', { error, stopId }); return null; } } diff --git a/apps/chronos/src/modules/kiosk/petrik-news.ts b/apps/chronos/src/modules/kiosk/petrik-news.ts index f35d8dcd..2bfafee2 100644 --- a/apps/chronos/src/modules/kiosk/petrik-news.ts +++ b/apps/chronos/src/modules/kiosk/petrik-news.ts @@ -3,6 +3,7 @@ import { DEFAULT_PETRIK_NEWS_MAX_ITEMS, navigatorKioskConfigSchema, } from '@filcdev/api/domains/kiosk/config'; +import { getLogger } from '@logtape/logtape'; import { ORPCError } from '@orpc/server'; import { eq } from 'drizzle-orm'; import { db } from '#database'; @@ -17,6 +18,8 @@ import { import { base } from '#orpc'; import { badGateway } from '#utils/http'; +const logger = getLogger(['chronos', 'kiosk', 'petrik-news']); + const PETRIK_NEWS_CACHE_TTL_MS = 10 * 60 * 1000; const PETRIK_NEWS_TIMEOUT_MS = 10_000; const MAX_FEED_BYTES = 1_048_576; @@ -186,8 +189,11 @@ export const petrikNews = base.kiosk.petrikNews.handler(async ({ input }) => { item.imageUrl = featured.get(stripTrailingSlash(item.url)) ?? item.imageUrl; } - } catch { + } catch (error) { // Best-effort fallback; keep the parsed items as-is on any failure. + // Logged because the REST lookup is the part that breaks when petrik.hu + // reshapes its response. + logger.warn('Failed to look up petrik.hu featured images', { error }); } } diff --git a/apps/chronos/src/modules/kiosk/utils/petrik-news.ts b/apps/chronos/src/modules/kiosk/utils/petrik-news.ts index dbc5a466..af93bfda 100644 --- a/apps/chronos/src/modules/kiosk/utils/petrik-news.ts +++ b/apps/chronos/src/modules/kiosk/utils/petrik-news.ts @@ -1,5 +1,8 @@ +import { getLogger } from '@logtape/logtape'; import { XMLParser } from 'fast-xml-parser'; +const logger = getLogger(['chronos', 'kiosk', 'petrik-news']); + /** One petrik.hu article, reduced to what the navigator slideshow needs. */ export type PetrikNewsItem = { /** Plain-text excerpt of the article body, or '' when there is none. */ @@ -144,7 +147,10 @@ export function parsePetrikNewsFeed(xml: string): PetrikNewsItem[] { let parsed: unknown; try { parsed = parser.parse(xml); - } catch { + } catch (error) { + // A feed that stops parsing is the whole kiosk slideshow going dark; the + // shape change that caused it is invisible without this. + logger.warn('Failed to parse the petrik.hu news feed', { error }); return []; } diff --git a/apps/chronos/src/modules/wifi/self.ts b/apps/chronos/src/modules/wifi/self.ts index c4d6107a..da6e97ff 100644 --- a/apps/chronos/src/modules/wifi/self.ts +++ b/apps/chronos/src/modules/wifi/self.ts @@ -81,7 +81,13 @@ const findCertFile = (targetPath: string): string | null => { return caCrt; } } - } catch { + } catch (error) { + // A permission failure on the directory is not "file absent"; the caller + // reports the path as missing either way. + logger.debug('Could not stat the WiFi CA cert path {targetPath}', { + error, + targetPath, + }); return null; } return null; diff --git a/apps/chronos/src/modules/wifi/utils/controller.ts b/apps/chronos/src/modules/wifi/utils/controller.ts index 23abbefe..0e6c9bcf 100644 --- a/apps/chronos/src/modules/wifi/utils/controller.ts +++ b/apps/chronos/src/modules/wifi/utils/controller.ts @@ -1,6 +1,9 @@ +import { getLogger } from '@logtape/logtape'; import { env } from '#utils/environment'; import { createUnifiController } from './unifi-controller'; +const logger = getLogger(['chronos', 'wifi', 'controller']); + export type WifiSpeedProfile = { id: string; name: string; @@ -46,6 +49,17 @@ export const getWifiController = (): WifiController | null => { env.unifiPassword ) ) { + // Without this the controller is just absent: enrichment never runs and + // every speed-profile call answers a bare 503 with no stated reason. + const missing = [ + !env.unifiHost && 'UNIFI_HOST', + !env.unifiUsername && 'UNIFI_USERNAME', + !env.unifiPassword && 'UNIFI_PASSWORD', + ].filter((field): field is string => field !== false); + logger.warn( + 'WiFi controller is set to unifi but {fields} is not configured, controller disabled', + { fields: missing.join(', ') } + ); controller = null; return controller; } diff --git a/apps/chronos/src/modules/wifi/utils/radius.ts b/apps/chronos/src/modules/wifi/utils/radius.ts index 1e09cfaa..ee5aac81 100644 --- a/apps/chronos/src/modules/wifi/utils/radius.ts +++ b/apps/chronos/src/modules/wifi/utils/radius.ts @@ -50,6 +50,11 @@ const normalizeSourceMac = (value: string): string => { try { return canonicalizeMac(value); } catch { + // A malformed MAC becomes the raw value, so it can only ever match an + // identical raw value: worth a line when the request is investigated. + logger.debug('Radius request with an unparseable source MAC', { + source: value, + }); return value.trim().toLowerCase(); } }; @@ -61,6 +66,9 @@ const normalizeNasMac = (value: string | undefined): string | null => { try { return canonicalizeMac(value); } catch { + // Distinct from "no NAS reported": the DB row then shows null where a + // usable NAS MAC should be. + logger.debug('Radius request with an unparseable NAS MAC', { nas: value }); return null; } }; @@ -160,7 +168,8 @@ const enrichControllerDevice = ( : undefined ), resolveEffectiveSpeedProfile(account).then((profileId) => - profileId ? controller.applySpeedProfile(mac, profileId) : undefined + // Always apply, send an empty string to the controller to reflect if the speed profile is null + controller.applySpeedProfile(mac, profileId ?? '') ), controller.updateClientName(mac, account.username), ]) diff --git a/apps/chronos/src/modules/wifi/utils/sync.ts b/apps/chronos/src/modules/wifi/utils/sync.ts index 8e76c967..1e7b7cb1 100644 --- a/apps/chronos/src/modules/wifi/utils/sync.ts +++ b/apps/chronos/src/modules/wifi/utils/sync.ts @@ -1,3 +1,4 @@ +import { getLogger } from '@logtape/logtape'; import { and, lt, notExists, sql } from 'drizzle-orm'; import { db } from '#database'; import { env } from '#utils/environment'; @@ -5,6 +6,8 @@ import { wifiSpeedProfile, wifiUser } from '../schema'; import { SPEED_PROFILE_NONE } from './constants'; import { getWifiController } from './controller'; +const logger = getLogger(['chronos', 'unifi', 'sync']); + /** * Mirror the UniFi controller's speed profiles into the local table. * @@ -19,6 +22,12 @@ export async function syncSpeedProfiles() { } const profiles = await controller.getSpeedProfiles(); + + if (profiles.length === 0) { + logger.warn('Received empty list of speed profile from Unifi controller'); + return; + } + let defaultProfileId: string | null = null; if (env.wifiSsid) { defaultProfileId = await controller.getWlanDefaultSpeedProfile( diff --git a/apps/chronos/src/modules/wifi/utils/unifi-api.ts b/apps/chronos/src/modules/wifi/utils/unifi-api.ts index c1f6127f..7eea60ba 100644 --- a/apps/chronos/src/modules/wifi/utils/unifi-api.ts +++ b/apps/chronos/src/modules/wifi/utils/unifi-api.ts @@ -1,3 +1,5 @@ +import { getLogger } from '@logtape/logtape'; + export type UnifiConfig = { baseUrl: string; insecureTls?: boolean; @@ -46,6 +48,25 @@ export class UnifiApiError extends Error { const LOGIN_REQUIRED = 'UNIFI_LOGIN_REQUIRED'; +const logger = getLogger(['chronos', 'wifi', 'unifi-api']); + +/** + * A UniFi endpoint answers `{ meta, data }`. `data` is missing when a proxy in + * front of the controller (or a controller version change) reshapes the body, + * and the array consumers below would then crash on `undefined` with a message + * that names neither the endpoint nor the controller. + */ +const dataArray = (data: T[] | undefined, context: string): T[] => { + if (!data) { + logger.error('UniFi {context} answered without a data array', { context }); + throw new UnifiApiError( + `UniFi ${context} answered without a data array`, + 'api.err.NoData' + ); + } + return data; +}; + /** A stalled controller must not pin a MAC in the enrichment debounce forever. */ const REQUEST_TIMEOUT_MS = 10_000; @@ -83,6 +104,10 @@ export class UnifiClient { return; } + logger.debug('Probing UniFi controller at {baseUrl}', { + baseUrl: this.config.baseUrl, + }); + const response = await fetch( this.config.baseUrl, this.baseInit({ redirect: 'manual' }) @@ -98,6 +123,17 @@ export class UnifiClient { this.isUnifiOS = true; } this.isInitialized = true; + + // A wrong flavour means every later request 404s through the wrong prefix, + // so the probe result is the first thing an operator needs to see. + logger.info( + 'UniFi controller {baseUrl} probed as {flavour} (status {status})', + { + baseUrl: this.config.baseUrl, + flavour: this.isUnifiOS ? 'unifi-os' : 'legacy', + status: response.status, + } + ); } // Parse and store cookies properly @@ -132,6 +168,23 @@ export class UnifiClient { } private async login(): Promise { + try { + await this.signIn(); + } catch (error) { + // Reached before the request's own logging: a controller that is down, + // unreachable or TLS-rejecting fails here, and the raw "Unable to + // connect" says nothing about which host was tried. + if (error instanceof Error && !(error instanceof UnifiApiError)) { + logger.error('UniFi login at {baseUrl} failed: {error}', { + baseUrl: this.config.baseUrl, + error, + }); + } + throw error; + } + } + + private async signIn(): Promise { await this.initializeEnvironment(); const loginPath = this.isUnifiOS ? '/api/auth/login' : '/api/login'; @@ -148,18 +201,38 @@ export class UnifiClient { ); if (!response.ok) { - throw new Error(`UniFi login failed with status ${response.status}`); + logger.error('UniFi login to {loginPath} failed with status {status}', { + loginPath, + status: response.status, + }); + throw new UnifiApiError( + `UniFi login failed with status ${response.status}` + ); } this.updateTokens(response); const body = (await response.json()) as UnifiResponse; if (body.meta?.rc === 'error') { - throw new Error(body.meta.msg ?? 'UniFi login failed'); + logger.error('UniFi login to {loginPath} was rejected: {reason}', { + loginPath, + reason: body.meta.msg ?? 'unknown', + }); + throw new UnifiApiError(body.meta.msg ?? 'UniFi login failed'); + } + + // A login that carries no cookie makes every later request re-login first. + if (this.cookies.length === 0) { + logger.warn( + 'UniFi login to {loginPath} succeeded without a session cookie', + { loginPath } + ); } + logger.debug('UniFi login to {loginPath} succeeded', { loginPath }); } async request(path: string, init: RequestInit = {}): Promise { + const method = init.method ?? 'GET'; const execute = async () => { const headers = new Headers(init.headers); headers.set('content-type', 'application/json'); @@ -172,18 +245,29 @@ export class UnifiClient { } const targetUrl = this.getUrl(path); + const startedAt = Date.now(); const response = await fetch( targetUrl, this.baseInit({ ...init, headers }) ); + const status = response.status; this.updateTokens(response); - if (response.status === 401) { + if (status === 401) { throw new Error(LOGIN_REQUIRED); } if (!response.ok) { - throw new Error(`UniFi request failed with status ${response.status}`); + // The controller answers a plain status for transport-level failures, + // so the endpoint is the only thing that says what was attempted. + logger.error('UniFi {method} {path} failed with status {status}', { + method, + path, + status, + }); + throw new UnifiApiError( + `UniFi ${method} ${path} failed with status ${status}` + ); } const body = (await response.json()) as UnifiResponse; @@ -191,11 +275,23 @@ export class UnifiClient { if (body.meta.msg === 'api.err.LoginRequired') { throw new Error(LOGIN_REQUIRED); } + logger.error('UniFi {method} {path} was rejected: {reason}', { + method, + path, + reason: body.meta.msg ?? 'unknown', + }); throw new UnifiApiError( body.meta.msg ?? 'UniFi request failed', body.meta.msg ); } + + logger.debug('UniFi {method} {path} -> {status} in {ms}ms', { + method, + ms: Date.now() - startedAt, + path, + status, + }); return body.data as T; }; @@ -207,10 +303,20 @@ export class UnifiClient { return await execute(); } catch (error) { if (error instanceof Error && error.message === LOGIN_REQUIRED) { + logger.warn('UniFi session expired, re-authenticating'); this.cookies = []; // Clear state to force re-auth await this.login(); return execute(); } + // DNS/TLS/timeout carry no status line, and the raw "Unable to connect" + // names neither the endpoint nor the controller. + if (error instanceof Error && !(error instanceof UnifiApiError)) { + logger.error('UniFi {method} {path} failed: {error}', { + error, + method, + path, + }); + } throw error; } } @@ -218,7 +324,12 @@ export class UnifiClient { // --- Endpoints --- getClientDevice = async (macAddress: string): Promise => - this.request(`/api/s/${this.config.site}/stat/user/${macAddress}`); + dataArray( + await this.request( + `/api/s/${this.config.site}/stat/user/${macAddress}` + ), + `GET stat/user/${macAddress}` + ); setClientName = async (clientId: string, name: string): Promise => this.request(`/api/s/${this.config.site}/rest/user/${clientId}`, { @@ -227,7 +338,12 @@ export class UnifiClient { }); getAccessDevices = async (): Promise => - this.request(`/api/s/${this.config.site}/stat/device`); + dataArray( + await this.request( + `/api/s/${this.config.site}/stat/device` + ), + 'GET stat/device' + ); restartDevice = async ( macAddress: string, @@ -243,29 +359,45 @@ export class UnifiClient { }); getUserGroups = async (): Promise => - this.request(`/api/s/${this.config.site}/rest/usergroup`); + dataArray( + await this.request( + `/api/s/${this.config.site}/rest/usergroup` + ), + 'GET rest/usergroup' + ); getWlanConf = async (): Promise => - this.request(`/api/s/${this.config.site}/rest/wlanconf`); + dataArray( + await this.request( + `/api/s/${this.config.site}/rest/wlanconf` + ), + 'GET rest/wlanconf' + ); createUserGroup = async ( name: string, downloadMbps = -1, uploadMbps = -1 ): Promise => { - const res = await this.request( - `/api/s/${this.config.site}/rest/usergroup`, - { - body: JSON.stringify({ - name, - qos_rate_max_down: downloadMbps === -1 ? -1 : downloadMbps * 1000, - qos_rate_max_up: uploadMbps === -1 ? -1 : uploadMbps * 1000, - }), - method: 'POST', - } + const res = dataArray( + await this.request( + `/api/s/${this.config.site}/rest/usergroup`, + { + body: JSON.stringify({ + name, + qos_rate_max_down: downloadMbps === -1 ? -1 : downloadMbps * 1000, + qos_rate_max_up: uploadMbps === -1 ? -1 : uploadMbps * 1000, + }), + method: 'POST', + } + ), + 'POST rest/usergroup' ); if (!res[0]) { - throw new Error('UniFi returned empty array'); + throw new UnifiApiError( + 'UniFi accepted the usergroup create but returned no row', + 'api.err.NoData' + ); } return res[0]; }; @@ -277,19 +409,25 @@ export class UnifiClient { downloadMbps: number, uploadMbps: number ): Promise => { - const res = await this.request( - `/api/s/${siteId}/rest/usergroup/${id}`, - { - body: JSON.stringify({ - name, - qos_rate_max_down: downloadMbps === -1 ? -1 : downloadMbps * 1000, - qos_rate_max_up: uploadMbps === -1 ? -1 : uploadMbps * 1000, - }), - method: 'PUT', - } + const res = dataArray( + await this.request( + `/api/s/${siteId}/rest/usergroup/${id}`, + { + body: JSON.stringify({ + name, + qos_rate_max_down: downloadMbps === -1 ? -1 : downloadMbps * 1000, + qos_rate_max_up: uploadMbps === -1 ? -1 : uploadMbps * 1000, + }), + method: 'PUT', + } + ), + `PUT rest/usergroup/${id}` ); if (!res[0]) { - throw new Error('UniFi returned empty array'); + throw new UnifiApiError( + 'UniFi accepted the usergroup edit but returned no row', + 'api.err.NoData' + ); } return res[0]; }; @@ -301,7 +439,16 @@ export class UnifiClient { fetchClientHostname = async (macAddress: string): Promise => { const devices = await this.getClientDevice(macAddress); - return devices.length === 1 ? (devices[0]?.hostname ?? null) : null; + if (devices.length !== 1) { + // `null` is a legitimate answer, but a device the controller stopped + // reporting is exactly what leaves `reported_hostname` stale forever. + logger.debug('UniFi reported {count} clients for {mac}', { + count: devices.length, + mac: macAddress, + }); + return null; + } + return devices[0]?.hostname ?? null; }; applySpeedProfile = async ( @@ -310,6 +457,12 @@ export class UnifiClient { ): Promise => { const [device] = await this.getClientDevice(macAddress); if (!device) { + // Silent without this: the caller asked for a limit on a MAC the + // controller has never seen, and nothing in the network would change. + logger.warn( + 'UniFi has no client {mac}, speed profile {profileId} not applied', + { mac: macAddress, profileId: speedProfileId } + ); return; } await this.request(`/api/s/${this.config.site}/rest/user/${device._id}`, { diff --git a/apps/chronos/src/modules/wifi/utils/unifi-controller.ts b/apps/chronos/src/modules/wifi/utils/unifi-controller.ts index 46e71260..6a6dd0f4 100644 --- a/apps/chronos/src/modules/wifi/utils/unifi-controller.ts +++ b/apps/chronos/src/modules/wifi/utils/unifi-controller.ts @@ -73,7 +73,14 @@ export class UnifiController implements WifiController { async getWlanDefaultSpeedProfile(ssid: string): Promise { const wlans = await this.client.getWlanConf(); const wlan = wlans.find((w) => w.name === ssid); - return wlan?.usergroup_id ?? null; + if (!wlan) { + logger.warn( + 'UniFi has no WLAN named {ssid}, no speed profile can be marked default', + { ssid } + ); + return null; + } + return wlan.usergroup_id ?? null; } async createSpeedProfile( diff --git a/apps/chronos/src/utils/entra-id-token.ts b/apps/chronos/src/utils/entra-id-token.ts index 1efa1a33..cf344c95 100644 --- a/apps/chronos/src/utils/entra-id-token.ts +++ b/apps/chronos/src/utils/entra-id-token.ts @@ -1,5 +1,8 @@ +import { getLogger } from '@logtape/logtape'; import { createRemoteJWKSet, jwtVerify } from 'jose'; +const logger = getLogger(['chronos', 'entra-id-token']); + /** Microsoft public cloud authority; the same default the Microsoft provider uses. */ const DEFAULT_AUTHORITY = 'https://login.microsoftonline.com'; @@ -49,8 +52,11 @@ export function createEntraIdTokenVerifier( }); return !nonce || payload.nonce === nonce; - } catch { - // Any failure (signature, audience, issuer, age, malformed token) is a rejection. + } catch (error) { + // Any failure (signature, audience, issuer, age, malformed token) is a + // rejection. Logged at debug so a JWKS outage is distinguishable from a + // genuinely bad token when someone reports a sign-in failure. + logger.debug('Entra ID token rejected', { error }); return false; } }; diff --git a/apps/iris/src/client.tsx b/apps/iris/src/client.tsx index 020092e1..bd46d461 100644 --- a/apps/iris/src/client.tsx +++ b/apps/iris/src/client.tsx @@ -1,4 +1,4 @@ -import { reactErrorHandler } from '@sentry/react'; +import { captureException, reactErrorHandler } from '@sentry/react'; import { StartClient } from '@tanstack/react-start/client'; import { StrictMode } from 'react'; import { hydrateRoot } from 'react-dom/client'; @@ -15,8 +15,12 @@ hydrateRoot( { onCaughtError: reactErrorHandler(), onRecoverableError: reactErrorHandler(), - onUncaughtError: reactErrorHandler((_error, _errorInfo) => { - // TODO: logger + onUncaughtError: reactErrorHandler((error, errorInfo) => { + // An error that escapes the React tree never reaches the query/mutation + // caches either, so this is the only place it can be captured. + captureException(error, { + extra: { componentStack: errorInfo.componentStack }, + }); }), } ); diff --git a/docs/architecture.md b/docs/architecture.md index 8130eae3..4dd73c25 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -43,6 +43,16 @@ External consumers get plain HTTP: every procedure also appears in the OpenAPI d - **Database**: Drizzle schema sources sit next to the feature that owns them (`apps/chronos/src/modules//schema.ts`), with only the shared identity and RBAC tables in `apps/chronos/src/database/schema`; `drizzle.config.ts` globs both. Migrations are generated (`bun run db:generate`) and never hand-edited. The repo root [`compose.yml`](../compose.yml) runs a local Postgres 18 instance (Alpine) as the dev database; run `docker compose up -d` and see CONTRIBUTING.md for the alternative `pg-dispo` flow. - **Success/errors**: a handler returns its payload and the route's `successStatus` supplies the status; failures throw `ORPCError`s built by the `#utils/http` helpers, all of whose codes and statuses come from the shared error map installed with `oc.errors(apiErrors)`. +## Observability + +- **Logging (chronos)**: LogTape (`@logtape/logtape`), configured once in `apps/chronos/src/utils/logger.ts`. One console sink, per-module categories (`getLogger(['chronos', ])`), the pretty formatter in development and JSON lines in production. The level is `CHRONOS_LOG_LEVEL` (`trace | debug | info | warning | error`, default `info`), documented in `apps/chronos/.env.example`. +- **Request log**: the `timingInterceptor` adapter interceptor in `src/index.ts` logs every request that reaches an oRPC handler — `url`, `method`, `status`, `duration`, `ip`, `ua`, `user` and the request id — at `info` in production and `trace` in development. `/api/auth/*`, the unsubscribe pages and the door-lock WebSocket are dispatched before the handlers and are deliberately *not* in that log. +- **Request id**: `buildContext` mints (or echoes an inbound `x-request-id`) a `crypto.randomUUID()` into the context; every response carries it as `x-request-id` and every log line for that request carries the same field, so a failure a user reported can be matched to the lines that produced it. +- **Errors**: a thrown non-`ORPCError` is logged by the `errorBoundaryInterceptor` client interceptor — stack plus request id, procedure path, client ip and user id — then rethrown as `INTERNAL`. Because that interceptor runs per procedure, it has the procedure's identity available; a defined `ORPCError` rethrows without a second log line, so a 4xx does not look like a bug. +- **Tracing**: Sentry, `@sentry/bun` on the server and `@sentry/react` in iris, initialised in `apps/chronos/src/utils/telemetry.ts` and `apps/iris/src/utils/telemetry.ts`. Chronos registers no integration explicitly: the Bun SDK's default `bunServerIntegration` instruments `Bun.serve` directly, which is what produces the per-request span the old Hono integration used to supply, so `CHRONOS_SENTRY_TRACES_SAMPLE_RATE` still controls performance sampling. `dataCollection` is pinned per field (cookies, user info, request bodies and database query data off) rather than by `sendDefaultPii`, matching the intent of the previous privacy baseline. +- **Where errors go**: a caught handler failure is a log line, not a Sentry event — Sentry gets unhandled rejections and the spans, so a 500 that a handler threw is found in the log stream by its request id. Iris is wired the other way round: `router.tsx`'s query and mutation caches `captureException`, and `client.tsx` captures what escapes the React tree. +- **Kiosk and packages**: kiosk has no logging of its own — its API failures surface through React Query and Sentry's browser integration. No shared package logs; anything that needs to is called by its app. + ## Frontend (iris) - **Data access** lives in domain hook modules (`apps/iris/src/hooks/.ts`): query + mutation hooks that own API calls, translated toasts, and query invalidation. Routes keep only UI state (filters, dialog open/close, selection). From 8ad27096519596f475dae4e7ea1206f1b989263c Mon Sep 17 00:00:00 2001 From: Tamas Vince Date: Fri, 16 Oct 2026 12:47:08 +0200 Subject: [PATCH 30/32] fix(chronos,iris): close a few holes, trace requests, dedupe contract schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the oRPC rewrite turned up 19 bugs; the four live ones were confirmed against a restore of a production dump. Correctness - notifications: the `unread` query transform ran on absent input, so the inbox default view returned read rows only (76k unread hidden in production) - navigator: the graph output reused the write schemas, so a mapped room with a null `type_id` 500'd the public campus graph (71 production rows are one admin edit away from that state) - doorlock: an unguarded `await` in the WebSocket `open` handler took the whole process down on any DB error. The better-auth API-key upgrade path is gone — the socket is aegis-only, so the device token is the only credential - notifications: stored preferences now resolve over the defaults, so a user with no row is no longer silently opted out of every type; the row is created on signup and a partial patch is merged instead of stored as-is - news: a cohort-less caller was served every cohort-targeted system message - CHRONOS_MODE defaults to production and is set in the Dockerfile; the test-notification endpoints are gated on CHRONOS_ENABLE_TEST_NOTIFICATIONS rather than on the mode - timetable: moved-lesson updates accept a partial body and write the row plus its lesson links in one transaction - doorlock: device tokens require 32 characters, are no longer logged, and the OTA URL must be https - kiosk: feed fetches connect to the validated literal IP, closing the DNS-rebinding window in the SSRF guard - size caps on bug-report metadata/search, on request bodies, and on the navigator import Observability - a request id is minted per request, carried on every log line and returned as `x-request-id` - the error boundary logs the procedure path, client ip, user id and request id - debug lines for the catches that used to swallow: session resolution, BKK departures, Entra token rejection, RADIUS MAC canonicalisation, cert stat and the petrik feed parses - iris captures errors that escape the React tree - docs: an observability section, and CHRONOS_MODE / the test flag in CONTRIBUTING and .env.example Contract - the navigator write shapes derive from the read shapes, so a body can no longer claim a column is non-null when the import left it null - collapse the duplicated schemas: classroomRowSchema, the four identical `*Ref` schemas, periodRef, the create/update twins, and the lessonIds refine - `news.systemMessages.list` no longer declares `auth: true` — it applies no guard - regenerate openapi/chronos-openapi.json, which also corrects a wifi MAC pattern that was already stale relative to its contract The regenerated document differs only in the field order of one `required` array. lint, typecheck (9/9) and build (3/3) pass; the schemas, the SSRF guard, the notification engine and the migrations were exercised against a real database. --- CONTRIBUTING.md | 5 + apps/chronos/.env.example | 13 +- apps/chronos/Dockerfile | 1 + apps/chronos/openapi/chronos-openapi.json | 195 +++++++++++------- apps/chronos/src/index.ts | 9 + .../src/modules/doorlock/device-socket.ts | 107 +++------- apps/chronos/src/modules/kiosk/index.ts | 1 + apps/chronos/src/modules/kiosk/petrik-news.ts | 17 +- .../src/modules/kiosk/utils/feed-url.ts | 35 +++- apps/chronos/src/modules/navigator/graph.ts | 11 +- .../chronos/src/modules/navigator/transfer.ts | 13 ++ .../src/modules/news/system-messages.ts | 8 + .../modules/notifications/notifications.ts | 66 ++++-- .../src/modules/notifications/schema.ts | 11 +- .../src/modules/timetable/moved-lesson.ts | 171 +++++++-------- apps/chronos/src/utils/authentication.ts | 10 + apps/chronos/src/utils/environment.ts | 12 +- .../chronos/src/utils/notifications/engine.ts | 17 +- apps/chronos/src/utils/notifications/queue.ts | 2 +- apps/chronos/src/utils/notifications/types.ts | 18 ++ apps/iris/public/locales/en/translation.json | 1 + apps/iris/public/locales/hu/translation.json | 1 + .../src/components/admin/kiosk-dialog.tsx | 1 + .../src/components/doorlock/device-dialog.tsx | 4 +- apps/iris/src/utils/form-schemas.ts | 7 - .../components/navigator/classroom-card.tsx | 4 +- .../src/components/navigator/search-panel.tsx | 6 +- apps/kiosk/src/routes/_kiosk/navigator.tsx | 4 +- apps/kiosk/src/utils/classroom-search.ts | 12 +- packages/api/src/contract/news.ts | 1 - packages/api/src/domains/bug-report.ts | 12 +- packages/api/src/domains/doorlock/devices.ts | 8 +- packages/api/src/domains/doorlock/ota.ts | 4 +- packages/api/src/domains/kiosk/crud.ts | 1 + .../api/src/domains/navigator/building.ts | 38 ++-- .../src/domains/navigator/classroom-type.ts | 51 +++-- .../api/src/domains/navigator/classroom.ts | 56 +++-- .../api/src/domains/navigator/corridor.ts | 43 ++-- packages/api/src/domains/navigator/graph.ts | 30 +-- packages/api/src/domains/navigator/lift.ts | 39 ++-- packages/api/src/domains/navigator/stair.ts | 39 ++-- .../api/src/domains/news/announcements.ts | 75 +++---- .../api/src/domains/news/system-messages.ts | 59 +++--- packages/api/src/domains/notifications.ts | 42 ++-- packages/api/src/domains/timetable/lesson.ts | 56 ++--- .../api/src/domains/timetable/moved-lesson.ts | 51 +++-- packages/navigator-3d/src/editor/merge.ts | 7 +- .../src/entities/building-helpers.ts | 6 +- .../src/entities/classroom-node.ts | 4 +- packages/navigator-3d/src/kiosk/palette.ts | 5 +- packages/navigator-3d/src/kiosk/types.ts | 5 +- .../navigator-3d/src/path/path-builder.ts | 8 +- packages/navigator-3d/src/scene/bounds.ts | 9 +- .../navigator-3d/src/scene/storey-resolver.ts | 6 +- 54 files changed, 794 insertions(+), 623 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dabe1f8a..430f8970 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,6 +45,11 @@ This guide gets you from a fresh clone to a running dev environment, then covers - `CHRONOS_DATABASE_URL` — point it at your local Postgres - Entra (Microsoft) OAuth values — see [docs/entra-setup.md](docs/entra-setup.md) + If you need the test-notification endpoints, set + `CHRONOS_ENABLE_TEST_NOTIFICATIONS=true` as well; they are off regardless of + the mode, because they can write into another user's inbox and send mail to + any address. + 4. **Optional** - Apply migrations: > [!tip] diff --git a/apps/chronos/.env.example b/apps/chronos/.env.example index 8dcd18e7..69d02ca9 100644 --- a/apps/chronos/.env.example +++ b/apps/chronos/.env.example @@ -46,10 +46,15 @@ CHRONOS_ENTRA_CLIENT_SECRET=FILL_ME_IN # statement when combined with CHRONOS_DRIZZLE_DEBUG. CHRONOS_LOG_LEVEL=debug -# development | production. Any non-production value makes error responses -# carry the underlying message and cause, and picks the pretty formatter over -# the production JSON lines (default: development) -# CHRONOS_MODE=development +# development | production (default: production). Development selects the +# pretty log formatter and makes error responses carry the underlying message +# and cause. It no longer gates the test-notification endpoints — see below. +CHRONOS_MODE=development + +# Enables `notifications.test` / `sendTest` / `previewTest`. Those can inject a +# notification of any type into another user's inbox and send mail to any +# address, so they stay off unless you ask for them (default: false). +# CHRONOS_ENABLE_TEST_NOTIFICATIONS=true # Log every SQL statement at trace level (default: false) # CHRONOS_DRIZZLE_DEBUG=false diff --git a/apps/chronos/Dockerfile b/apps/chronos/Dockerfile index b741186f..83dd2d94 100644 --- a/apps/chronos/Dockerfile +++ b/apps/chronos/Dockerfile @@ -18,6 +18,7 @@ RUN bun run build --filter=@filcdev/chronos FROM base AS release ENV NODE_ENV=production +ENV CHRONOS_MODE=production ENV CHRONOS_PORT=3001 COPY --from=builder /app/apps/chronos/src/database/migrations ./src/database/migrations COPY --from=builder /app/apps/chronos/dist ./dist diff --git a/apps/chronos/openapi/chronos-openapi.json b/apps/chronos/openapi/chronos-openapi.json index 7d3a8655..047f9298 100644 --- a/apps/chronos/openapi/chronos-openapi.json +++ b/apps/chronos/openapi/chronos-openapi.json @@ -986,7 +986,8 @@ "metadata": { "type": "object", "propertyNames": { - "type": "string" + "type": "string", + "maxLength": 64 }, "additionalProperties": {} }, @@ -1275,7 +1276,8 @@ "name": "search", "in": "query", "schema": { - "type": "string" + "type": "string", + "maxLength": 200 }, "allowEmptyValue": true, "allowReserved": true @@ -4272,7 +4274,8 @@ "properties": { "apiToken": { "type": "string", - "minLength": 1 + "minLength": 32, + "maxLength": 128 }, "lastResetReason": { "anyOf": [ @@ -5097,7 +5100,8 @@ "properties": { "apiToken": { "type": "string", - "minLength": 1 + "minLength": 32, + "maxLength": 128 }, "lastResetReason": { "anyOf": [ @@ -5749,7 +5753,7 @@ "properties": { "url": { "type": "string", - "format": "uri" + "pattern": "^https:\\/\\/" } }, "required": [ @@ -5979,7 +5983,7 @@ "properties": { "url": { "type": "string", - "format": "uri" + "pattern": "^https:\\/\\/" } }, "required": [ @@ -8468,6 +8472,9 @@ "type": "object", "properties": { "config": {}, + "enabled": { + "type": "boolean" + }, "kind": { "enum": [ "tv", @@ -16234,8 +16241,7 @@ "type": "boolean" }, "name": { - "type": "string", - "minLength": 1 + "type": "string" }, "x": { "type": "number" @@ -16260,21 +16266,31 @@ "type": "object", "properties": { "colorhex": { + "type": "string" + }, + "createdAt": { "type": "string", - "pattern": "^#[0-9a-fA-F]{6}(?:[0-9a-fA-F]{2})?$" + "format": "date-time", + "x-native-type": "date" }, "id": { "type": "string" }, "name": { + "type": "string" + }, + "updatedAt": { "type": "string", - "minLength": 1 + "format": "date-time", + "x-native-type": "date" } }, "required": [ "colorhex", + "createdAt", "id", - "name" + "name", + "updatedAt" ] } }, @@ -16284,13 +16300,19 @@ "type": "object", "properties": { "building_id": { - "type": "string", - "minLength": 1 + "type": "string" }, "capacity": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] }, "description": { "type": "string" @@ -16302,8 +16324,7 @@ "type": "boolean" }, "name": { - "type": "string", - "minLength": 1 + "type": "string" }, "rotation": { "type": "number" @@ -16326,8 +16347,14 @@ "maximum": 9007199254740991 }, "type_id": { - "type": "string", - "minLength": 1 + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] }, "x": { "type": "number" @@ -16364,8 +16391,12 @@ "type": "boolean" }, "building_id": { + "type": "string" + }, + "createdAt": { "type": "string", - "minLength": 1 + "format": "date-time", + "x-native-type": "date" }, "id": { "type": "string" @@ -16374,14 +16405,18 @@ "type": "boolean" }, "name": { - "type": "string", - "minLength": 1 + "type": "string" }, "storey": { "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, "width": { "type": "number" }, @@ -16401,10 +16436,12 @@ "required": [ "barrier_free", "building_id", + "createdAt", "id", "is_outdoor", "name", "storey", + "updatedAt", "width", "x1", "x2", @@ -16419,8 +16456,12 @@ "type": "object", "properties": { "building_id": { + "type": "string" + }, + "createdAt": { "type": "string", - "minLength": 1 + "format": "date-time", + "x-native-type": "date" }, "id": { "type": "string" @@ -16436,8 +16477,12 @@ "maximum": 9007199254740991 }, "name": { + "type": "string" + }, + "updatedAt": { "type": "string", - "minLength": 1 + "format": "date-time", + "x-native-type": "date" }, "x": { "type": "number" @@ -16448,10 +16493,12 @@ }, "required": [ "building_id", + "createdAt", "id", "max_storey", "min_storey", "name", + "updatedAt", "x", "y" ] @@ -16463,8 +16510,12 @@ "type": "object", "properties": { "building_id": { + "type": "string" + }, + "createdAt": { "type": "string", - "minLength": 1 + "format": "date-time", + "x-native-type": "date" }, "id": { "type": "string" @@ -16480,28 +16531,34 @@ "maximum": 9007199254740991 }, "name": { + "type": "string" + }, + "rotation": { + "type": "number" + }, + "updatedAt": { "type": "string", - "minLength": 1 + "format": "date-time", + "x-native-type": "date" }, "x": { "type": "number" }, "y": { "type": "number" - }, - "rotation": { - "type": "number" } }, "required": [ "building_id", + "createdAt", "id", "max_storey", "min_storey", "name", + "rotation", + "updatedAt", "x", - "y", - "rotation" + "y" ] } } @@ -18190,13 +18247,13 @@ "type": "string", "minLength": 1 }, - "x": { + "rotation": { "type": "number" }, - "y": { + "x": { "type": "number" }, - "rotation": { + "y": { "type": "number" } }, @@ -18205,9 +18262,9 @@ "max_storey", "min_storey", "name", + "rotation", "x", - "y", - "rotation" + "y" ] } } @@ -19066,13 +19123,13 @@ "type": "string", "minLength": 1 }, - "x": { + "rotation": { "type": "number" }, - "y": { + "x": { "type": "number" }, - "rotation": { + "y": { "type": "number" } } @@ -26689,13 +26746,7 @@ } }, "x-filc_group_name": "SystemMessage", - "x-filc_type_name": "@listof SystemMessage @field(.author, Author)", - "x-filc_auth": "true", - "security": [ - { - "sessionAuth": [] - } - ] + "x-filc_type_name": "@listof SystemMessage @field(.author, Author)" } }, "/news/system-messages/{id}": { @@ -41493,7 +41544,7 @@ } ], "requestBody": { - "required": true, + "required": false, "content": { "application/json": { "schema": { @@ -41523,7 +41574,14 @@ } }, "room": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] }, "startingDay": { "type": "string", @@ -41533,14 +41591,7 @@ "type": "string", "format": "uuid" } - }, - "required": [ - "date", - "lessonIds", - "room", - "startingDay", - "startingPeriod" - ] + } } } } @@ -45132,7 +45183,7 @@ }, "macAddress": { "type": "string", - "pattern": "^([0-9a-fA-F]{2}[:-]){5}([0-9a-fA-F]{2})$" + "pattern": "^(?:[0-9a-fA-F]{12}|(?:[0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2})$" }, "nickname": { "anyOf": [ @@ -45210,7 +45261,7 @@ }, "macAddress": { "type": "string", - "pattern": "^([0-9a-fA-F]{2}[:-]){5}([0-9a-fA-F]{2})$" + "pattern": "^(?:[0-9a-fA-F]{12}|(?:[0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2})$" }, "nickname": { "anyOf": [ @@ -45575,7 +45626,7 @@ }, "macAddress": { "type": "string", - "pattern": "^([0-9a-fA-F]{2}[:-]){5}([0-9a-fA-F]{2})$" + "pattern": "^(?:[0-9a-fA-F]{12}|(?:[0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2})$" }, "nickname": { "anyOf": [ @@ -46098,7 +46149,7 @@ }, "macAddress": { "type": "string", - "pattern": "^([0-9a-fA-F]{2}[:-]){5}([0-9a-fA-F]{2})$" + "pattern": "^(?:[0-9a-fA-F]{12}|(?:[0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2})$" }, "nickname": { "anyOf": [ @@ -46173,7 +46224,7 @@ }, "macAddress": { "type": "string", - "pattern": "^([0-9a-fA-F]{2}[:-]){5}([0-9a-fA-F]{2})$" + "pattern": "^(?:[0-9a-fA-F]{12}|(?:[0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2})$" }, "nickname": { "anyOf": [ @@ -46455,7 +46506,7 @@ }, "macAddress": { "type": "string", - "pattern": "^([0-9a-fA-F]{2}[:-]){5}([0-9a-fA-F]{2})$" + "pattern": "^(?:[0-9a-fA-F]{12}|(?:[0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2})$" } }, "required": [ @@ -46501,7 +46552,7 @@ }, "macAddress": { "type": "string", - "pattern": "^([0-9a-fA-F]{2}[:-]){5}([0-9a-fA-F]{2})$" + "pattern": "^(?:[0-9a-fA-F]{12}|(?:[0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2})$" }, "updatedAt": { "type": "string", @@ -46761,7 +46812,7 @@ }, "macAddress": { "type": "string", - "pattern": "^([0-9a-fA-F]{2}[:-]){5}([0-9a-fA-F]{2})$" + "pattern": "^(?:[0-9a-fA-F]{12}|(?:[0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2})$" }, "updatedAt": { "type": "string", @@ -47249,7 +47300,7 @@ }, "macAddress": { "type": "string", - "pattern": "^([0-9a-fA-F]{2}[:-]){5}([0-9a-fA-F]{2})$" + "pattern": "^(?:[0-9a-fA-F]{12}|(?:[0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2})$" } } } @@ -47291,7 +47342,7 @@ }, "macAddress": { "type": "string", - "pattern": "^([0-9a-fA-F]{2}[:-]){5}([0-9a-fA-F]{2})$" + "pattern": "^(?:[0-9a-fA-F]{12}|(?:[0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2})$" }, "updatedAt": { "type": "string", @@ -51847,7 +51898,7 @@ "type": "array", "items": { "type": "string", - "pattern": "^([0-9a-fA-F]{2}[:-]){5}([0-9a-fA-F]{2})$" + "pattern": "^(?:[0-9a-fA-F]{12}|(?:[0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2})$" } }, "comment": { @@ -51916,7 +51967,7 @@ "type": "array", "items": { "type": "string", - "pattern": "^([0-9a-fA-F]{2}[:-]){5}([0-9a-fA-F]{2})$" + "pattern": "^(?:[0-9a-fA-F]{12}|(?:[0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2})$" } }, { @@ -52284,7 +52335,7 @@ "type": "array", "items": { "type": "string", - "pattern": "^([0-9a-fA-F]{2}[:-]){5}([0-9a-fA-F]{2})$" + "pattern": "^(?:[0-9a-fA-F]{12}|(?:[0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2})$" } }, { @@ -52852,7 +52903,7 @@ "type": "array", "items": { "type": "string", - "pattern": "^([0-9a-fA-F]{2}[:-]){5}([0-9a-fA-F]{2})$" + "pattern": "^(?:[0-9a-fA-F]{12}|(?:[0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2})$" } }, "comment": { @@ -52927,7 +52978,7 @@ "type": "array", "items": { "type": "string", - "pattern": "^([0-9a-fA-F]{2}[:-]){5}([0-9a-fA-F]{2})$" + "pattern": "^(?:[0-9a-fA-F]{12}|(?:[0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2})$" } }, { diff --git a/apps/chronos/src/index.ts b/apps/chronos/src/index.ts index 1e85921b..9f28cca6 100644 --- a/apps/chronos/src/index.ts +++ b/apps/chronos/src/index.ts @@ -228,6 +228,14 @@ const buildContext = async ( requestId: request.headers.get('x-request-id') ?? crypto.randomUUID(), }); +/** + * Ceiling for any request body, enforced by Bun before the handler buffers it. + * The largest legitimate upload is the 20 MiB timetable import + * (`modules/timetable/import.ts`); Bun's own default is 128 MiB, which is a lot + * of memory to hand a single unauthenticated-shaped request. + */ +const MAX_REQUEST_BODY_BYTES = 25 * 1024 * 1024; + export const server = Bun.serve({ fetch: async (request, srv) => { const url = new URL(request.url); @@ -265,6 +273,7 @@ export const server = Bun.serve({ return final; }, + maxRequestBodySize: MAX_REQUEST_BODY_BYTES, port: env.port, websocket: deviceSocketHandlers, }); diff --git a/apps/chronos/src/modules/doorlock/device-socket.ts b/apps/chronos/src/modules/doorlock/device-socket.ts index 0b23cce5..01065dd4 100644 --- a/apps/chronos/src/modules/doorlock/device-socket.ts +++ b/apps/chronos/src/modules/doorlock/device-socket.ts @@ -15,8 +15,6 @@ import { type OutgoingMessage, outgoingMessageSchema, } from '#modules/doorlock/utils/schemas'; -import { auth } from '#utils/authentication'; -import { userHasPermission } from '#utils/authorization'; import { dispatchImmediateNotification } from '#utils/notifications/engine'; const logger = getLogger(['chronos', 'doorlock', 'websocket']); @@ -198,89 +196,32 @@ export async function upgrade( request: Request, srv: Bun.Server ): Promise { - // Primary auth: the device's provisioned token. const gotToken = request.headers.get('X-Aegis-Device-Token'); - if (gotToken) { - const [device] = await db - .select() - .from(lockDevice) - .where(eq(lockDevice.apiToken, gotToken)) - .limit(1); - - if (!device) { - logger.debug('WebSocket connection attempt with invalid device token', { - token: gotToken, - }); - return new Response(null, { status: 401 }); - } - - const d = { id: device.id, name: device.name }; - logger.debug('WebSocket connection authorized via device token', { - device: d, - }); - return connect(request, srv, d); - } - - // Alternative auth: a user-generated API key, resolved through the - // better-auth plugin (`enableSessionForAPIKeys`) so the key's own enable - // flag, expiry and rate limit all apply. The connecting client must also - // identify which device it represents. - // `getSession` throws on a key that is present but unusable (invalid or - // disabled), so a failed key is treated as "not this credential" and the - // request falls through to the 401 below rather than failing the handler. - const apiSession = await auth.api - .getSession({ headers: request.headers }) - .catch(() => null); - if (apiSession) { - const apiUser = apiSession.user; - const canControl = await userHasPermission( - apiUser.id, - 'doorlock:devices:read' + if (!gotToken) { + logger.warn('WebSocket connection attempt without a device token'); + return Response.json( + { message: 'Authentication required: provide X-Aegis-Device-Token' }, + { status: 401 } ); - if (!canControl) { - logger.debug('WebSocket connection with insufficient API key scope', { - userId: apiUser.id, - }); - return new Response(null, { status: 403 }); - } - - const deviceId = new URL(request.url).searchParams.get('deviceId'); - if (!deviceId) { - return Response.json( - { - message: 'deviceId query parameter is required when using an API key', - }, - { status: 400 } - ); - } - - const [device] = await db - .select() - .from(lockDevice) - .where(eq(lockDevice.id, deviceId)) - .limit(1); + } - if (!device) { - return Response.json({ message: 'Device not found' }, { status: 404 }); - } + const [device] = await db + .select() + .from(lockDevice) + .where(eq(lockDevice.apiToken, gotToken)) + .limit(1); - const d = { id: device.id, name: device.name }; - logger.debug('WebSocket connection authorized via API key', { - device: d, - userId: apiUser.id, - }); - return connect(request, srv, d); + if (!device) { + logger.debug('WebSocket connection attempt with an invalid device token'); + return new Response(null, { status: 401 }); } - logger.warn('WebSocket connection attempt without any credentials'); - return Response.json( - { - message: - 'Authentication required: provide X-Aegis-Device-Token or an API key', - }, - { status: 401 } - ); + const d = { id: device.id, name: device.name }; + logger.debug('WebSocket connection authorized via device token', { + device: d, + }); + return connect(request, srv, d); } function connect( @@ -327,6 +268,14 @@ export const handlers: Bun.WebSocketHandler = { device, }); ws.subscribe(`device-${device.id}`); - await syncDatabase(device.id); + + try { + await syncDatabase(device.id); + } catch (error) { + logger.error('Failed to sync the card database to a device', { + device, + error, + }); + } }, }; diff --git a/apps/chronos/src/modules/kiosk/index.ts b/apps/chronos/src/modules/kiosk/index.ts index 90ef1d87..462fb3ef 100644 --- a/apps/chronos/src/modules/kiosk/index.ts +++ b/apps/chronos/src/modules/kiosk/index.ts @@ -78,6 +78,7 @@ export const createKiosk = base.kiosk.create .insert(kiosk) .values({ config, + enabled: input.enabled ?? true, kind: input.kind, machineId: input.machineId, name: input.name, diff --git a/apps/chronos/src/modules/kiosk/petrik-news.ts b/apps/chronos/src/modules/kiosk/petrik-news.ts index 2bfafee2..d3443f95 100644 --- a/apps/chronos/src/modules/kiosk/petrik-news.ts +++ b/apps/chronos/src/modules/kiosk/petrik-news.ts @@ -8,7 +8,7 @@ import { ORPCError } from '@orpc/server'; import { eq } from 'drizzle-orm'; import { db } from '#database'; import { kiosk } from '#modules/kiosk/schema'; -import { assertAllowedFeedUrl } from '#modules/kiosk/utils/feed-url'; +import { fetchAllowedFeedUrl } from '#modules/kiosk/utils/feed-url'; import { type PetrikNewsItem, parseFeaturedImages, @@ -75,9 +75,10 @@ async function readCappedText( * Fetch featured images from the WordPress REST API at the feed's own origin. * petrik.hu's RSS carries no featured image, so `wp/v2/posts` is the primary * picture source — a post's banner replaces its first in-body `` as the - * default. The REST host is the feed's own origin, already validated by - * `assertAllowedFeedUrl`. A non-ok response means "no featured images - * available", leaving the in-body fallback in place. + * default. The REST host is the feed's own origin, and the request goes through + * `fetchAllowedFeedUrl` so the same SSRF guard and IP pinning apply. A non-ok + * response means "no featured images available", leaving the in-body fallback + * in place. */ async function fetchFeaturedImages( feedUrl: string @@ -89,7 +90,7 @@ async function fetchFeaturedImages( // `_fields` is set, so restricting fields would strip the `source_url` the // fallback needs. The uncapped response is bounded by `MAX_JSON_BYTES`. - const response = await fetch(restUrl, { + const response = await fetchAllowedFeedUrl(restUrl.toString(), { headers: { 'user-agent': 'filc-kiosk/1.0 (+https://filc.petrik.hu)' }, redirect: 'manual', signal: AbortSignal.timeout(PETRIK_NEWS_TIMEOUT_MS), @@ -147,17 +148,15 @@ export const petrikNews = base.kiosk.petrikNews.handler(async ({ input }) => { } // Serve the cache without a DNS round-trip; only a miss reaches the SSRF - // guard, so a warm cache never pays for `assertAllowedFeedUrl`. + // guard, so a warm cache never pays for the validation. const cached = feedCache.get(feed.feedUrl); if (cached && cached.expiresAt > Date.now()) { return { items: cached.items.slice(0, feed.maxItems) }; } - await assertAllowedFeedUrl(feed.feedUrl); - let xml: string; try { - const response = await fetch(feed.feedUrl, { + const response = await fetchAllowedFeedUrl(feed.feedUrl, { headers: { 'user-agent': 'filc-kiosk/1.0 (+https://filc.petrik.hu)' }, redirect: 'manual', signal: AbortSignal.timeout(PETRIK_NEWS_TIMEOUT_MS), diff --git a/apps/chronos/src/modules/kiosk/utils/feed-url.ts b/apps/chronos/src/modules/kiosk/utils/feed-url.ts index 31df8662..29ce24a2 100644 --- a/apps/chronos/src/modules/kiosk/utils/feed-url.ts +++ b/apps/chronos/src/modules/kiosk/utils/feed-url.ts @@ -228,11 +228,19 @@ export function isPrivateAddress(address: string): boolean { const feedNotAllowed = () => badRequest('Feed URL is not allowed'); /** - * SSRF protection for the public petrik-news endpoint: only https URLs whose - * hostname is a public DNS name resolving to non-private addresses may be - * fetched. Fails closed on any DNS error. + * SSRF protection for the kiosk feed fetches: only https URLs whose hostname is + * a public DNS name resolving to non-private addresses are fetched, and the + * connection is pinned to the address that was checked. + * + * Pinning matters: resolving a name and then letting `fetch` resolve it again + * leaves a rebinding window, because the second lookup is free to answer with + * `127.0.0.1`. Connecting to the validated literal IP, with the hostname sent as + * `Host` and used as the TLS SNI name, closes it. Fails closed on any DNS error. */ -export async function assertAllowedFeedUrl(url: string): Promise { +export async function fetchAllowedFeedUrl( + url: string, + init: Omit = {} +): Promise { let parsed: URL; try { parsed = new URL(url); @@ -269,7 +277,26 @@ export async function assertAllowedFeedUrl(url: string): Promise { throw feedNotAllowed(); }); + if (addresses.length === 0) { + throw feedNotAllowed(); + } + if (addresses.some(({ address }) => isPrivateAddress(address))) { throw feedNotAllowed(); } + + // Every address is public, so any is safe to pin to. IPv4 first: it is the + // family a dual-stack host is virtually always reachable on, and pinning to + // an unreachable IPv6 address would fail the fetch outright. + const chosen = addresses.find(({ family }) => family === 4) ?? addresses[0]; + const pinned = `https://${chosen.family === 6 ? `[${chosen.address}]` : chosen.address}${parsed.port ? `:${parsed.port}` : ''}${parsed.pathname}${parsed.search}`; + + return fetch(pinned, { + ...init, + headers: { + ...Object.fromEntries(new Headers(init.headers)), + host: hostname, + }, + tls: { serverName: hostname }, + } as RequestInit); } diff --git a/apps/chronos/src/modules/navigator/graph.ts b/apps/chronos/src/modules/navigator/graph.ts index 1f3aa557..cbe58bf7 100644 --- a/apps/chronos/src/modules/navigator/graph.ts +++ b/apps/chronos/src/modules/navigator/graph.ts @@ -47,13 +47,10 @@ export const graphRoute = base.navigator.graph.handler(async () => { return { buildings, classroom_types: classroomTypes, - // The graph's shape has no nullable campus fields: only placed rooms are - // here, and placing one is what fills them in. - classrooms: classrooms.map((row) => ({ - ...row, - capacity: row.capacity ?? 0, - type_id: row.type_id ?? '', - })), + // Passed through as stored: a room the timetable imported carries a null + // `type_id`/`capacity` until an admin places it, and the output schema + // above is shaped to accept exactly that. + classrooms, corridors, lifts, stairs, diff --git a/apps/chronos/src/modules/navigator/transfer.ts b/apps/chronos/src/modules/navigator/transfer.ts index 3ef8503f..6cc83ad8 100644 --- a/apps/chronos/src/modules/navigator/transfer.ts +++ b/apps/chronos/src/modules/navigator/transfer.ts @@ -1,4 +1,5 @@ import { permissions } from '@filcdev/api/permissions'; +import { ORPCError } from '@orpc/server'; import { asc, sql } from 'drizzle-orm'; import { db } from '#database'; import { requireAuthorization } from '#middleware/auth'; @@ -22,6 +23,9 @@ import { import { base } from '#orpc'; import { badRequest } from '#utils/http'; +/** Matches the timetable importer's ceiling; the payload is parsed as one JSON string. */ +const MAX_NAVIGATOR_IMPORT_BYTES = 20 * 1024 * 1024; + type TxOrDb = typeof db | Parameters[0]>[0]; /** @@ -170,6 +174,15 @@ export const importNavigatorRoute = base.navigator.import .handler(async ({ input }) => { // The upload is a navigator export file. A body that is not JSON and a // body that fails the transfer schema are both the caller's fault. + if (input.file.size > MAX_NAVIGATOR_IMPORT_BYTES) { + // Same ceiling class as the timetable import. Checked before the body is + // read so an oversized file is never parsed into memory. + throw new ORPCError('BAD_REQUEST', { + message: 'The uploaded navigator file is too large.', + status: 413, + }); + } + let payload: NavigatorTransfer; try { payload = navigatorImportSchema.parse( diff --git a/apps/chronos/src/modules/news/system-messages.ts b/apps/chronos/src/modules/news/system-messages.ts index da5d2839..bf690665 100644 --- a/apps/chronos/src/modules/news/system-messages.ts +++ b/apps/chronos/src/modules/news/system-messages.ts @@ -28,6 +28,10 @@ export const listSystemMessages = base.news.systemMessages.list.handler( conditions.push(gte(systemMessage.validUntil, now)); } + // A message with no cohort rows is global; a targeted one is only visible + // to its cohorts. A user without a cohort therefore sees global messages + // only — without this branch the predicate is skipped entirely and they + // would receive messages targeted at every other cohort. if (userCohortId) { conditions.push( sql`( @@ -35,6 +39,10 @@ export const listSystemMessages = base.news.systemMessages.list.handler( OR EXISTS (SELECT 1 FROM system_message_cohort_mtm WHERE system_message_id = ${systemMessage.id} AND cohort_id = ${userCohortId}) )` ); + } else { + conditions.push( + sql`NOT EXISTS (SELECT 1 FROM system_message_cohort_mtm WHERE system_message_id = ${systemMessage.id})` + ); } const where = conditions.length > 0 ? and(...conditions) : undefined; diff --git a/apps/chronos/src/modules/notifications/notifications.ts b/apps/chronos/src/modules/notifications/notifications.ts index 7def3683..ba7ca719 100644 --- a/apps/chronos/src/modules/notifications/notifications.ts +++ b/apps/chronos/src/modules/notifications/notifications.ts @@ -17,6 +17,7 @@ import { sendEmail, } from '#utils/notifications/providers/smtp'; import { enqueue } from '#utils/notifications/queue'; +import { DEFAULT_NOTIFICATION_PREFERENCES } from '#utils/notifications/types'; export const listNotifications = base.notifications.list .use(requireAuthentication) @@ -109,6 +110,27 @@ export const markAllAsRead = base.notifications.markAllAsRead return { ok: true as const }; }); +/** + * Complete a stored preferences record from the defaults. + * + * Rows written before the update path merged a partial patch can be missing + * keys, and the response schema requires all of them — without this a user + * whose row was written that way could never open their settings again. + */ +const withCompletePreferences = < + T extends { notificationPreferences: unknown }, +>( + row: T +) => ({ + ...row, + notificationPreferences: { + ...DEFAULT_NOTIFICATION_PREFERENCES, + ...(row.notificationPreferences as Partial< + typeof DEFAULT_NOTIFICATION_PREFERENCES + >), + }, +}); + export const getNotificationSettings = base.notifications.settings .use(requireAuthentication) .handler(async ({ context }) => { @@ -120,7 +142,7 @@ export const getNotificationSettings = base.notifications.settings .where(eq(userPreferences.userId, userId)); if (prefs) { - return prefs; + return withCompletePreferences(prefs); } const [created] = await db @@ -134,7 +156,7 @@ export const getNotificationSettings = base.notifications.settings }); } - return created; + return withCompletePreferences(created); }); export const updateNotificationSettings = base.notifications.updateSettings @@ -154,9 +176,6 @@ export const updateNotificationSettings = base.notifications.updateSettings if (body.timetableView !== undefined) { values.timetableView = body.timetableView; } - if (body.notificationPreferences !== undefined) { - values.notificationPreferences = body.notificationPreferences; - } if (body.timetableClassColors !== undefined) { values.timetableClassColors = body.timetableClassColors; } @@ -164,6 +183,26 @@ export const updateNotificationSettings = base.notifications.updateSettings values.timetableGroupDisplay = body.timetableGroupDisplay; } + // A patch may name a subset of the toggles, but the column has to hold a + // complete record: a stored object missing a key reads as `undefined` + // downstream, which the engine treats as an opt-out (and fails the + // response schema on read). Merge over what is stored, or the defaults. + const [existing] = await db + .select({ + notificationPreferences: userPreferences.notificationPreferences, + }) + .from(userPreferences) + .where(eq(userPreferences.userId, userId)) + .limit(1); + + if (body.notificationPreferences !== undefined) { + values.notificationPreferences = { + ...DEFAULT_NOTIFICATION_PREFERENCES, + ...existing?.notificationPreferences, + ...body.notificationPreferences, + }; + } + const [updated] = await db .update(userPreferences) .set(values) @@ -171,7 +210,7 @@ export const updateNotificationSettings = base.notifications.updateSettings .returning(); if (updated) { - return updated; + return withCompletePreferences(updated); } const [created] = await db @@ -185,7 +224,7 @@ export const updateNotificationSettings = base.notifications.updateSettings }); } - return created; + return withCompletePreferences(created); }); export const registerFcmToken = base.notifications.fcmTokens.register @@ -216,9 +255,9 @@ export const registerFcmToken = base.notifications.fcmTokens.register export const testNotification = base.notifications.test .use(requireAuthentication) .handler(async ({ context }) => { - if (env.mode !== 'development') { + if (!env.enableTestNotifications) { throw forbidden( - 'Test notifications can only be sent in development mode' + 'Test notifications are disabled (set CHRONOS_ENABLE_TEST_NOTIFICATIONS=true)' ); } @@ -257,6 +296,7 @@ export const testNotification = base.notifications.test channelsEnabled: prefs?.notificationPreferences?.channelsEnabled ?? true, content: notif.content, email: userRecords[0]?.email ?? '', + language: prefs?.language ?? 'hu', notificationId: notif.id, title: notif.title, type: 'test', @@ -344,9 +384,9 @@ async function resolveTestTarget( export const sendTestNotification = base.notifications.sendTest .use(requireAuthentication) .handler(async ({ context, input }) => { - if (env.mode !== 'development') { + if (!env.enableTestNotifications) { throw forbidden( - 'Test notifications can only be sent in development mode' + 'Test notifications are disabled (set CHRONOS_ENABLE_TEST_NOTIFICATIONS=true)' ); } @@ -391,9 +431,9 @@ export const sendTestNotification = base.notifications.sendTest export const previewTestNotification = base.notifications.previewTest .use(requireAuthentication) .handler(async ({ input }) => { - if (env.mode !== 'development') { + if (!env.enableTestNotifications) { throw forbidden( - 'Test notifications can only be previewed in development mode' + 'Test notifications are disabled (set CHRONOS_ENABLE_TEST_NOTIFICATIONS=true)' ); } diff --git a/apps/chronos/src/modules/notifications/schema.ts b/apps/chronos/src/modules/notifications/schema.ts index 8ea7cf6d..ad93e623 100644 --- a/apps/chronos/src/modules/notifications/schema.ts +++ b/apps/chronos/src/modules/notifications/schema.ts @@ -10,6 +10,7 @@ import { import { timestamps } from '#database/helpers'; import { user } from '#database/schema/authentication'; import type { NotificationPreferences } from '#utils/notifications/types'; +import { DEFAULT_NOTIFICATION_PREFERENCES } from '#utils/notifications/types'; export const userPreferences = pgTable('user_preferences', { id: uuid('id').primaryKey().defaultRandom(), @@ -17,15 +18,7 @@ export const userPreferences = pgTable('user_preferences', { notificationPreferences: jsonb('notification_preferences') .$type() .notNull() - .default({ - announcement: true, - blogPost: false, - channelsEnabled: true, - doorlockCardUsed: false, - movedLesson: true, - substitution: true, - systemMessage: true, - }), + .default(DEFAULT_NOTIFICATION_PREFERENCES), theme: text('theme').default('system').notNull(), timetableClassColors: jsonb('timetable_class_colors') .$type>() diff --git a/apps/chronos/src/modules/timetable/moved-lesson.ts b/apps/chronos/src/modules/timetable/moved-lesson.ts index 5d6c6616..f7a2ae50 100644 --- a/apps/chronos/src/modules/timetable/moved-lesson.ts +++ b/apps/chronos/src/modules/timetable/moved-lesson.ts @@ -18,7 +18,6 @@ import { enrichLessons, } from '#modules/timetable/utils/enrich-lessons'; import { base } from '#orpc'; -import { env } from '#utils/environment'; import { badRequest, notFound } from '#utils/http'; import { cancelPendingNotification, @@ -348,51 +347,46 @@ export const createMovedLesson = base.timetable.movedLessons.create startingPeriod, }); - const [newMovedLesson] = await db - .insert(movedLesson) - .values({ - comment, - date, - id: crypto.randomUUID(), - room, - startingDay, - startingPeriod, - }) - .returning(); + // Row and lesson links are one unit: a failure between them leaves a moved + // lesson with no lessons, which nothing downstream can repair. + const newMovedLesson = await db.transaction(async (tx) => { + const [row] = await tx + .insert(movedLesson) + .values({ + comment, + date, + id: crypto.randomUUID(), + room, + startingDay, + startingPeriod, + }) + .returning(); + + if (!row) { + throw new ORPCError('INTERNAL', { + message: 'Failed to create moved lesson', + }); + } - if ( - lessonIds && - Array.isArray(lessonIds) && - lessonIds.length > 0 && - newMovedLesson - ) { - await db.insert(movedLessonLessonMTM).values( - lessonIds.map((lessonId: string) => ({ - lessonId, - movedLessonId: newMovedLesson.id, - })) - ); - } + if (lessonIds && lessonIds.length > 0) { + await tx.insert(movedLessonLessonMTM).values( + lessonIds.map((lessonId: string) => ({ + lessonId, + movedLessonId: row.id, + })) + ); + } - if (newMovedLesson) { - dispatchPendingNotification(newMovedLesson.id, 'moved_lesson', { - date: body.date, - lessonIds: lessonIds ?? [], - room, - startingDay, - startingPeriod, - }); - } + return row; + }); - if (!newMovedLesson) { - throw new ORPCError('INTERNAL', { - cause: - env.mode === 'development' - ? 'No moved lesson returned from insert query' - : undefined, - message: 'Failed to create moved lesson.', - }); - } + dispatchPendingNotification(newMovedLesson.id, 'moved_lesson', { + date: newMovedLesson.date, + lessonIds: lessonIds ?? [], + room: newMovedLesson.room, + startingDay: newMovedLesson.startingDay, + startingPeriod: newMovedLesson.startingPeriod, + }); return newMovedLesson; }); @@ -400,57 +394,64 @@ export const createMovedLesson = base.timetable.movedLessons.create export const updateMovedLesson = base.timetable.movedLessons.update .use(requireAuthorization(permissions.movedLessonUpdate)) .handler(async ({ input }) => { - const { id } = input; - const { startingPeriod, startingDay, room, date, lessonIds, comment } = - input; + const { id, lessonIds, ...columns } = input; - await validateMovedLessonReferences({ - lessonIds, - room, - startingDay, - startingPeriod, - }); + await validateMovedLessonReferences({ ...columns, lessonIds }); cancelPendingNotification(id, 'moved_lesson'); - const [updatedMovedLesson] = await db - .update(movedLesson) - .set({ - comment: comment === undefined ? undefined : comment, - date, - room: room === undefined ? undefined : room, - startingDay: startingDay === undefined ? undefined : startingDay, - startingPeriod: - startingPeriod === undefined ? undefined : startingPeriod, - }) - .where(eq(movedLesson.id, id)) - .returning(); + // The row and its lesson links are one unit: a failure between them would + // leave a moved lesson attached to the wrong lessons. Both writes go in one + // transaction, and the row is read back so an empty patch still 404s. + const updatedMovedLesson = await db.transaction(async (tx) => { + const [row] = + Object.keys(columns).length > 0 + ? await tx + .update(movedLesson) + .set(columns) + .where(eq(movedLesson.id, id)) + .returning() + : await tx.select().from(movedLesson).where(eq(movedLesson.id, id)); + + if (!row) { + throw notFound('Moved lesson not found'); + } - if (!updatedMovedLesson) { - throw notFound('Moved lesson not found'); - } + if (lessonIds !== undefined) { + await tx + .delete(movedLessonLessonMTM) + .where(eq(movedLessonLessonMTM.movedLessonId, id)); + + if (lessonIds.length > 0) { + await tx.insert(movedLessonLessonMTM).values( + lessonIds.map((lessonId: string) => ({ + lessonId, + movedLessonId: id, + })) + ); + } + } - if (lessonIds !== undefined && Array.isArray(lessonIds)) { - await db - .delete(movedLessonLessonMTM) - .where(eq(movedLessonLessonMTM.movedLessonId, id)); + return row; + }); - if (lessonIds.length > 0) { - await db.insert(movedLessonLessonMTM).values( - lessonIds.map((lessonId: string) => ({ - lessonId, - movedLessonId: id, - })) - ); - } - } + // A partial patch must not notify an empty audience, so the payload is the + // merged result rather than just what the caller sent. + const effectiveLessonIds = + lessonIds ?? + ( + await db + .select({ lessonId: movedLessonLessonMTM.lessonId }) + .from(movedLessonLessonMTM) + .where(eq(movedLessonLessonMTM.movedLessonId, id)) + ).map(({ lessonId }) => lessonId); dispatchPendingNotification(id, 'moved_lesson', { - date, - lessonIds: lessonIds ?? [], - room, - startingDay, - startingPeriod, + date: updatedMovedLesson.date, + lessonIds: effectiveLessonIds, + room: updatedMovedLesson.room, + startingDay: updatedMovedLesson.startingDay, + startingPeriod: updatedMovedLesson.startingPeriod, }); return updatedMovedLesson; diff --git a/apps/chronos/src/utils/authentication.ts b/apps/chronos/src/utils/authentication.ts index ef1def84..ddac4b1c 100644 --- a/apps/chronos/src/utils/authentication.ts +++ b/apps/chronos/src/utils/authentication.ts @@ -11,6 +11,7 @@ import { authenticationSchema, user as userTable, } from '#database/schema/authentication'; +import { userPreferences } from '#modules/notifications/schema'; import { teacher } from '#modules/timetable/schema'; import { wifiUser } from '#modules/wifi/schema'; import { getUserPermissions } from '#utils/authorization'; @@ -176,6 +177,15 @@ const authOptions = { }, user: { create: { + after: async (user) => { + // Every user owns a preferences row from the start. Creating it + // lazily leaves a new user with none, and the notification engine + // could not then tell "no row" from "all toggles on". + await db + .insert(userPreferences) + .values({ userId: user.id }) + .onConflictDoNothing({ target: userPreferences.userId }); + }, before: async (user, _ctx) => ({ data: { ...user, diff --git a/apps/chronos/src/utils/environment.ts b/apps/chronos/src/utils/environment.ts index 8f5b124c..3a865849 100644 --- a/apps/chronos/src/utils/environment.ts +++ b/apps/chronos/src/utils/environment.ts @@ -22,6 +22,11 @@ const envShape = z.object({ CHRONOS_BKK_API_KEY: z.string().optional(), CHRONOS_DATABASE_URL: z.url(), CHRONOS_DRIZZLE_DEBUG: boolean.default(false), + // Gates the three test-notification endpoints (`notifications.test`, + // `sendTest`, `previewTest`). They can inject a notification of any type into + // another user's inbox and send mail to an arbitrary address, so they are off + // unless explicitly requested. + CHRONOS_ENABLE_TEST_NOTIFICATIONS: boolean.default(false), CHRONOS_ENTRA_CLIENT_ID: z.string(), CHRONOS_ENTRA_CLIENT_SECRET: z.string().optional(), CHRONOS_ENTRA_TENANT_ID: z.string(), @@ -36,7 +41,12 @@ const envShape = z.object({ CHRONOS_LOG_LEVEL: z .enum(['trace', 'debug', 'info', 'warning', 'error']) .default('info'), - CHRONOS_MODE: z.enum(['development', 'production']).default('development'), + // Defaults to production: the development branch opens the test-notification + // endpoints to every signed-in user, echoes internal error messages to + // clients and reflects any CORS origin. Making it explicit opt-in means a + // deployment that forgets to set it fails safe. Set CHRONOS_MODE=development + // to get the pretty logs and the test endpoints. + CHRONOS_MODE: z.enum(['development', 'production']).default('production'), CHRONOS_NOTIFICATION_DELAY_ANNOUNCEMENT: z.coerce.number().default(60), CHRONOS_NOTIFICATION_DELAY_BLOG_POST: z.coerce.number().default(60), CHRONOS_NOTIFICATION_DELAY_COHORT_RESELECTION_REQUIRED: z.coerce diff --git a/apps/chronos/src/utils/notifications/engine.ts b/apps/chronos/src/utils/notifications/engine.ts index 72647f06..efd597cf 100644 --- a/apps/chronos/src/utils/notifications/engine.ts +++ b/apps/chronos/src/utils/notifications/engine.ts @@ -9,8 +9,10 @@ import type { AudienceUser, NotificationContent, NotificationHandler, + NotificationPreferences, NotificationType, } from '#utils/notifications/types'; +import { DEFAULT_NOTIFICATION_PREFERENCES } from '#utils/notifications/types'; const logger = getLogger(['chronos', 'notifications', 'engine']); @@ -242,8 +244,17 @@ async function fireNotification( .where(eq(userPreferences.userId, user.id)) .limit(1); + // A user who never opened their settings has no row, and a row written + // before a toggle existed lacks that key; both fall back per key rather + // than reading `undefined`, which the gate below would treat as an + // opt-out. + const preferences: NotificationPreferences = { + ...DEFAULT_NOTIFICATION_PREFERENCES, + ...prefs?.notificationPreferences, + }; + const prefKey = handler.preferenceKey; - if (prefs && prefKey && !prefs.notificationPreferences[prefKey]) { + if (prefKey && !preferences[prefKey]) { logger.debug('User {userId} opted out of notification type {type}', { type, userId: user.id, @@ -251,8 +262,7 @@ async function fireNotification( continue; } - const channelsEnabled = - prefs?.notificationPreferences.channelsEnabled ?? true; + const channelsEnabled = preferences.channelsEnabled; const content: NotificationContent = handler.buildContent( payload, user.language @@ -274,6 +284,7 @@ async function fireNotification( channelsEnabled, content: content.content, email: user.email, + language: user.language, notificationId: notif.id, title: content.title, type, diff --git a/apps/chronos/src/utils/notifications/queue.ts b/apps/chronos/src/utils/notifications/queue.ts index e52201b1..f032e873 100644 --- a/apps/chronos/src/utils/notifications/queue.ts +++ b/apps/chronos/src/utils/notifications/queue.ts @@ -33,7 +33,7 @@ async function processJob(job: DeliveryJob): Promise { const emailPromise = sendEmail( job.email, job.type, - 'hu', + job.language, { content: job.content, title: job.title, diff --git a/apps/chronos/src/utils/notifications/types.ts b/apps/chronos/src/utils/notifications/types.ts index b4d75bf2..ce685608 100644 --- a/apps/chronos/src/utils/notifications/types.ts +++ b/apps/chronos/src/utils/notifications/types.ts @@ -19,6 +19,22 @@ export type NotificationPreferences = { channelsEnabled: boolean; }; +/** + * What a user who has never touched their settings gets. Also the base a + * stored record is merged over, so a row written before a toggle existed — or + * a partial one — falls back per key instead of reading `undefined`, which + * would silently opt the user out of that type. + */ +export const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = { + announcement: true, + blogPost: false, + channelsEnabled: true, + doorlockCardUsed: false, + movedLesson: true, + substitution: true, + systemMessage: true, +}; + export type AudienceUser = { id: string; email: string; @@ -55,4 +71,6 @@ export type DeliveryJob = { title: string; content: string; channelsEnabled: boolean; + /** Locale the recipient's `buildContent` ran with; the email wrapper uses it too. */ + language: string; }; diff --git a/apps/iris/public/locales/en/translation.json b/apps/iris/public/locales/en/translation.json index 74ba1770..a8272474 100644 --- a/apps/iris/public/locales/en/translation.json +++ b/apps/iris/public/locales/en/translation.json @@ -202,6 +202,7 @@ "importSuccessTitle": "Import Successful", "importSuccessMessage": "The timetable has been imported and all related data has been updated.", "importErrorTitle": "Import Failed", + "loadError": "Failed to load the timetable", "importInfo": "Important Information", "importInfoPoint1": "Only XML files exported from OMAN are supported", "importInfoPoint2": "The import process will update all timetable-related data including cohorts, subjects, teachers, and lessons", diff --git a/apps/iris/public/locales/hu/translation.json b/apps/iris/public/locales/hu/translation.json index c66b2dc9..b0cae3e7 100644 --- a/apps/iris/public/locales/hu/translation.json +++ b/apps/iris/public/locales/hu/translation.json @@ -184,6 +184,7 @@ "importSuccessTitle": "Sikeres importálás", "importSuccessMessage": "Az órarend importálva lett, és minden kapcsolódó adat frissítve lett.", "importErrorTitle": "Sikertelen importálás", + "loadError": "Nem sikerült betölteni az órarendet", "importInfo": "Fontos információk", "importInfoPoint1": "Csak OMAN-ból exportált XML fájlok támogatottak", "importInfoPoint2": "Az importálási folyamat frissíti az összes órarenddel kapcsolatos adatot, beleértve a csoportokat, tárgyakat, tanárokat és órákat", diff --git a/apps/iris/src/components/admin/kiosk-dialog.tsx b/apps/iris/src/components/admin/kiosk-dialog.tsx index 997d7845..c1fd10b2 100644 --- a/apps/iris/src/components/admin/kiosk-dialog.tsx +++ b/apps/iris/src/components/admin/kiosk-dialog.tsx @@ -183,6 +183,7 @@ export function KioskDialog({ kiosk, onOpenChange, open }: KioskDialogProps) { } createKiosk.mutate({ config, + enabled: value.enabled, kind: value.kind, machineId: value.machineId, name: value.name, diff --git a/apps/iris/src/components/doorlock/device-dialog.tsx b/apps/iris/src/components/doorlock/device-dialog.tsx index 046ef0f2..c301b3e3 100644 --- a/apps/iris/src/components/doorlock/device-dialog.tsx +++ b/apps/iris/src/components/doorlock/device-dialog.tsx @@ -1,3 +1,4 @@ +import { devicePayloadSchema } from '@filcdev/api/domains/doorlock/devices'; import { Button } from '@filcdev/ui/components/button'; import { Dialog, @@ -17,7 +18,6 @@ import type { DeviceLike, } from '@/components/doorlock/doorlock.types'; import { useUpsertDoorlockDevice } from '@/hooks/doorlock-admin'; -import { deviceSchema } from '@/utils/form-schemas'; const initialState = (device?: DeviceLike | null): DeviceFormValues => ({ apiToken: device?.apiToken ?? crypto.randomUUID(), @@ -47,7 +47,7 @@ export function DeviceDialog({ }, }); }, - validators: { onSubmit: deviceSchema }, + validators: { onSubmit: devicePayloadSchema }, }); useEffect(() => { diff --git a/apps/iris/src/utils/form-schemas.ts b/apps/iris/src/utils/form-schemas.ts index 355a503f..b0eed5d0 100644 --- a/apps/iris/src/utils/form-schemas.ts +++ b/apps/iris/src/utils/form-schemas.ts @@ -20,13 +20,6 @@ export const createCardSchema = cardBaseSchema.refine( export const updateCardSchema = cardBaseSchema; -export const deviceSchema = z.object({ - apiToken: z.string().min(1, 'API token is required'), - lastResetReason: z.string().nullable().optional(), - location: z.string().nullable().optional(), - name: z.string().min(1, 'Name is required'), -}); - export const userFormSchema = z.object({ nickname: z.string(), roles: z.array(z.string()), diff --git a/apps/kiosk/src/components/navigator/classroom-card.tsx b/apps/kiosk/src/components/navigator/classroom-card.tsx index 02d0a835..a7fd5c74 100644 --- a/apps/kiosk/src/components/navigator/classroom-card.tsx +++ b/apps/kiosk/src/components/navigator/classroom-card.tsx @@ -1,11 +1,11 @@ -import type { Classroom } from '@filcdev/api/domains/navigator/classroom'; +import type { ClassroomRow } from '@filcdev/api/domains/navigator/classroom'; import { Button } from '@filcdev/ui/components/button'; import { Card } from '@filcdev/ui/components/card'; import { type RefObject, useLayoutEffect, useRef, useState } from 'react'; import type { ClassroomInfo, Translator } from '@/utils/classroom-search'; type ClassroomCardProps = { - classroom: Classroom; + classroom: ClassroomRow; info: ClassroomInfo; itemRefs: RefObject>; onNavigate: (id: string) => void; diff --git a/apps/kiosk/src/components/navigator/search-panel.tsx b/apps/kiosk/src/components/navigator/search-panel.tsx index 593f95cf..69303032 100644 --- a/apps/kiosk/src/components/navigator/search-panel.tsx +++ b/apps/kiosk/src/components/navigator/search-panel.tsx @@ -58,8 +58,10 @@ export const SearchPanel = memo(function SearchPanelImpl({ return found; } - return found.filter((classroom) => - selectedTypeIds.includes(classroom.type_id) + return found.filter( + (classroom) => + classroom.type_id !== null && + selectedTypeIds.includes(classroom.type_id) ); }, [graph, query, resolve, selectedTypeIds, t]); diff --git a/apps/kiosk/src/routes/_kiosk/navigator.tsx b/apps/kiosk/src/routes/_kiosk/navigator.tsx index 79cc10bb..df624ff6 100644 --- a/apps/kiosk/src/routes/_kiosk/navigator.tsx +++ b/apps/kiosk/src/routes/_kiosk/navigator.tsx @@ -4,7 +4,7 @@ import { DEFAULT_PETRIK_NEWS_ENABLED, DEFAULT_PETRIK_NEWS_IDLE_SECONDS, } from '@filcdev/api/domains/kiosk/config'; -import type { Classroom } from '@filcdev/api/domains/navigator/classroom'; +import type { ClassroomRow } from '@filcdev/api/domains/navigator/classroom'; import type { MyLocation } from '@filcdev/api/domains/navigator/my-location'; import type { KioskNode } from '@filcdev/navigator-3d/kiosk/types'; import type { @@ -199,7 +199,7 @@ function NavigatorKioskPage() { const [viewResetToken, setViewResetToken] = useState(0); const classroomById = useMemo(() => { - const map = new Map(); + const map = new Map(); for (const classroom of graph?.classrooms ?? []) { map.set(classroom.id, classroom); } diff --git a/apps/kiosk/src/utils/classroom-search.ts b/apps/kiosk/src/utils/classroom-search.ts index 0d2f27fb..6506316a 100644 --- a/apps/kiosk/src/utils/classroom-search.ts +++ b/apps/kiosk/src/utils/classroom-search.ts @@ -1,4 +1,4 @@ -import type { Classroom } from '@filcdev/api/domains/navigator/classroom'; +import type { ClassroomRow } from '@filcdev/api/domains/navigator/classroom'; import type { FullGraph } from '@filcdev/api/domains/navigator/graph'; /** Combining diacritical marks, stripped after NFD. */ @@ -49,7 +49,7 @@ export function slugify(value: string): string { * `'b' + building.id`, e.g. `classroom.desc.b1.a05`). */ export function classroomDescriptionKey( - classroom: Classroom, + classroom: ClassroomRow, buildingId: string ): string { return `classroom.desc.b${buildingId}.${slugify(classroom.name)}`; @@ -58,7 +58,7 @@ export function classroomDescriptionKey( /** A classroom plus the display strings the kiosk shows alongside it. */ export type ClassroomInfo = { buildingName: string; - classroom: Classroom; + classroom: ClassroomRow; /** * Resolved description: the bundle text for the derived * `classroom.desc.*` key when present, otherwise `classroom.description`, @@ -91,7 +91,7 @@ export function storeyLabel(storey: number, t: Translator): string { */ export function classroomInfo( graph: FullGraph, - classroom: Classroom, + classroom: ClassroomRow, t: Translator, resolve: Resolver ): ClassroomInfo { @@ -123,11 +123,11 @@ export function searchClassrooms( query: string, t: Translator, resolve: Resolver -): Classroom[] { +): ClassroomRow[] { const rooms = graph?.classrooms ?? []; const q = normalize(query); - const values: Classroom[] = []; + const values: ClassroomRow[] = []; for (const classroom of rooms) { if ( diff --git a/packages/api/src/contract/news.ts b/packages/api/src/contract/news.ts index e3f06596..a4001508 100644 --- a/packages/api/src/contract/news.ts +++ b/packages/api/src/contract/news.ts @@ -351,7 +351,6 @@ export const newsContract = { list: oc .route( filcRoute({ - auth: true, description: 'List active system messages within date range, filtered by user cohort', group: 'SystemMessage', diff --git a/packages/api/src/domains/bug-report.ts b/packages/api/src/domains/bug-report.ts index fe3a6426..c9b8a5f2 100644 --- a/packages/api/src/domains/bug-report.ts +++ b/packages/api/src/domains/bug-report.ts @@ -3,7 +3,15 @@ import z from 'zod'; /** Payload for `POST /api/bug-report`. */ export const createBugReportSchema = z.object({ description: z.string().min(10).max(5000), - metadata: z.record(z.string(), z.unknown()).optional(), + // Bounded like the sibling text fields: the column is jsonb and the caller + // is any signed-in user, so an unbounded record is an unbounded write. + metadata: z + .record(z.string().max(64), z.unknown()) + .refine( + (value) => JSON.stringify(value).length <= 4096, + 'Metadata must serialize to at most 4096 characters' + ) + .optional(), page: z.string().max(255).optional(), subject: z.string().min(3).max(200), }); @@ -32,7 +40,7 @@ export const bugReportListQuerySchema = z.object({ dateTo: z.iso.datetime().optional(), limit: z.coerce.number().int().min(1).max(100).default(20), page: z.coerce.number().int().min(1).default(1), - search: z.string().optional(), + search: z.string().max(200).optional(), status: bugReportStatusSchema.optional(), }); diff --git a/packages/api/src/domains/doorlock/devices.ts b/packages/api/src/domains/doorlock/devices.ts index df14bdbe..09e4ba12 100644 --- a/packages/api/src/domains/doorlock/devices.ts +++ b/packages/api/src/domains/doorlock/devices.ts @@ -7,7 +7,13 @@ export type IdParamInput = z.infer; /** Payload for creating or updating a doorlock device. */ export const devicePayloadSchema = z.object({ - apiToken: z.string().min(1, 'API token is required'), + // The token is the device's only credential and grants its card database, so + // a guessable one is a door. The admin UI generates a UUID; this keeps a + // hand-typed token in the same strength class. + apiToken: z + .string() + .min(32, 'API token must be at least 32 characters') + .max(128), lastResetReason: z.string().trim().optional().nullable(), location: z.string().trim().optional().nullable(), name: z.string().min(1, 'Device name is required'), diff --git a/packages/api/src/domains/doorlock/ota.ts b/packages/api/src/domains/doorlock/ota.ts index 3635d664..1182806f 100644 --- a/packages/api/src/domains/doorlock/ota.ts +++ b/packages/api/src/domains/doorlock/ota.ts @@ -2,7 +2,9 @@ import z from 'zod'; /** Payload for triggering a firmware OTA update. */ export const otaPayloadSchema = z.object({ - url: z.url('A valid firmware URL is required'), + url: z + .url('A valid firmware URL is required') + .regex(/^https:\/\//i, 'The firmware URL must use https'), }); export type OtaPayloadInput = z.infer; diff --git a/packages/api/src/domains/kiosk/crud.ts b/packages/api/src/domains/kiosk/crud.ts index 697a4f14..4edbf3fe 100644 --- a/packages/api/src/domains/kiosk/crud.ts +++ b/packages/api/src/domains/kiosk/crud.ts @@ -8,6 +8,7 @@ import { kioskKindSchema } from './config'; */ export const createKioskSchema = z.object({ config: z.unknown().optional(), + enabled: z.boolean().optional(), kind: kioskKindSchema, machineId: z.string().min(1).max(64), name: z.string().min(1), diff --git a/packages/api/src/domains/navigator/building.ts b/packages/api/src/domains/navigator/building.ts index 2fd2b8c9..39c3690d 100644 --- a/packages/api/src/domains/navigator/building.ts +++ b/packages/api/src/domains/navigator/building.ts @@ -1,18 +1,32 @@ import z from 'zod'; /** - * A campus building. `x`/`y` are its origin in the shared world frame the - * pathfinder works in, so building and classroom coordinates are comparable. + * A building row as the API returns it: the columns, deliberately unconstrained + * beyond their types. The campus import writes rows (an empty name is possible) + * that the API must still be able to read back, so the read shape cannot carry + * the write-time `min(1)`. + * + * This is the base definition; the write shape narrows it (see below). Deriving + * one from the other is what keeps them from drifting — a column added here + * reaches both. */ -export const buildingSchema = z.object({ +export const buildingRowSchema = z.object({ description: z.string(), id: z.string(), mapped: z.boolean(), - name: z.string().min(1), + name: z.string(), x: z.number(), y: z.number(), }); +export type BuildingRow = z.infer; + +/** A campus building. `x`/`y` are its origin in the shared world frame the + * pathfinder works in, so building and classroom coordinates are comparable. */ +export const buildingSchema = buildingRowSchema.extend({ + name: z.string().min(1), +}); + export const createBuildingSchema = buildingSchema.omit({ id: true, mapped: true, @@ -24,22 +38,6 @@ export type Building = z.infer; export type CreateBuildingInput = z.infer; export type UpdateBuildingInput = z.infer; -/** - * A building row as the API returns it. Deliberately unconstrained beyond the - * column types: the campus import writes rows (an empty name is possible) that - * the API must still be able to read back. - */ -export const buildingRowSchema = z.object({ - description: z.string(), - id: z.string(), - mapped: z.boolean(), - name: z.string(), - x: z.number(), - y: z.number(), -}); - -export type BuildingRow = z.infer; - /** Payload of `GET /navigator/buildings`. */ export const buildingsResponseSchema = z.object({ buildings: z.array(buildingRowSchema), diff --git a/packages/api/src/domains/navigator/classroom-type.ts b/packages/api/src/domains/navigator/classroom-type.ts index 9a180eaf..a17a4524 100644 --- a/packages/api/src/domains/navigator/classroom-type.ts +++ b/packages/api/src/domains/navigator/classroom-type.ts @@ -1,24 +1,44 @@ import z from 'zod'; /** - * A classroom category; `colorhex` is the fill colour used in the 3D views. + * The colour a classroom type fills with in the 3D views. * * `#RRGGBBAA` is accepted alongside `#RRGGBB`: the upstream campus capture (and * so the imported rows) carries an alpha pair, and the 3D palette reads the * leading six digits (`packages/navigator-3d/src/kiosk/palette.ts`), so both - * forms describe the same colour. + * forms describe the same colour. The read shape leaves it unconstrained, since + * whatever the capture wrote has to come back out. */ -export const classroomTypeSchema = z.object({ - colorhex: z - .string() - .regex( - /^#[0-9a-fA-F]{6}(?:[0-9a-fA-F]{2})?$/, - 'Colour must be a hex value like #1a2b3c or #1a2b3cff' - ), +const colorhexPattern = /^#[0-9a-fA-F]{6}(?:[0-9a-fA-F]{2})?$/; + +/** + * A classroom-type row as the API returns it: the columns, unconstrained beyond + * their types (see `buildingRowSchema` for why). The base definition; the write + * shape narrows it. + */ +export const classroomTypeRowSchema = z.object({ + colorhex: z.string(), + createdAt: z.date(), id: z.string(), - name: z.string().min(1), + name: z.string(), + updatedAt: z.date(), }); +export type ClassroomTypeRow = z.infer; + +/** A classroom category; `colorhex` is the fill colour used in the 3D views. */ +export const classroomTypeSchema = classroomTypeRowSchema + .omit({ createdAt: true, updatedAt: true }) + .extend({ + colorhex: z + .string() + .regex( + colorhexPattern, + 'Colour must be a hex value like #1a2b3c or #1a2b3cff' + ), + name: z.string().min(1), + }); + export const createClassroomTypeSchema = classroomTypeSchema.omit({ id: true }); export const updateClassroomTypeSchema = createClassroomTypeSchema.partial(); @@ -31,17 +51,6 @@ export type UpdateClassroomTypeInput = z.infer< typeof updateClassroomTypeSchema >; -/** A classroom-type row as the API returns it (see `buildingRowSchema`). */ -export const classroomTypeRowSchema = z.object({ - colorhex: z.string(), - createdAt: z.date(), - id: z.string(), - name: z.string(), - updatedAt: z.date(), -}); - -export type ClassroomTypeRow = z.infer; - /** Payload of `GET /navigator/classroom-types`. */ export const classroomTypesResponseSchema = z.object({ classroom_types: z.array(classroomTypeRowSchema), diff --git a/packages/api/src/domains/navigator/classroom.ts b/packages/api/src/domains/navigator/classroom.ts index e63d9298..c4099246 100644 --- a/packages/api/src/domains/navigator/classroom.ts +++ b/packages/api/src/domains/navigator/classroom.ts @@ -1,27 +1,45 @@ import z from 'zod'; /** - * A room on a storey of a building. Sizes and rotation are real numbers: the - * 3D editor allows free rotation, so rotation is not constrained to quarters. + * A classroom row as the API returns it: the columns, deliberately + * unconstrained beyond their types. `capacity` and `type_id` are null for the + * rooms the timetable imported before anyone placed them on the map, and the + * campus import can write an empty name — the API has to be able to read those + * rows back, so the read shape cannot carry the write-time constraints. + * + * This is the base definition; the write shape narrows it. */ -export const classroomSchema = z.object({ - building_id: z.string().min(1), - capacity: z.number().int(), +export const classroomRowSchema = z.object({ + building_id: z.string(), + capacity: z.number().int().nullable(), description: z.string(), id: z.string(), mapped: z.boolean(), - name: z.string().min(1), + name: z.string(), rotation: z.number(), short: z.string(), size_x: z.number(), size_y: z.number(), size_z: z.number(), storey: z.number().int(), - type_id: z.string().min(1), + type_id: z.string().nullable(), x: z.number(), y: z.number(), }); +export type ClassroomRow = z.infer; + +/** + * A room on a storey of a building. Sizes and rotation are real numbers: the + * 3D editor allows free rotation, so rotation is not constrained to quarters. + */ +export const classroomSchema = classroomRowSchema.extend({ + building_id: z.string().min(1), + capacity: z.number().int(), + name: z.string().min(1), + type_id: z.string().min(1), +}); + export const createClassroomSchema = classroomSchema.omit({ id: true, mapped: true, @@ -33,30 +51,6 @@ export type Classroom = z.infer; export type CreateClassroomInput = z.infer; export type UpdateClassroomInput = z.infer; -/** - * A classroom row as the API returns it. `capacity` and `type_id` are null for - * the rooms the timetable imported before anyone placed them on the map. - */ -export const classroomRowSchema = z.object({ - building_id: z.string(), - capacity: z.number().int().nullable(), - description: z.string(), - id: z.string(), - mapped: z.boolean(), - name: z.string(), - rotation: z.number(), - short: z.string(), - size_x: z.number(), - size_y: z.number(), - size_z: z.number(), - storey: z.number().int(), - type_id: z.string().nullable(), - x: z.number(), - y: z.number(), -}); - -export type ClassroomRow = z.infer; - /** Payload of `GET /navigator/classrooms`. */ export const classroomsResponseSchema = z.object({ classrooms: z.array(classroomRowSchema), diff --git a/packages/api/src/domains/navigator/corridor.ts b/packages/api/src/domains/navigator/corridor.ts index 4ae7459d..c9bf6d96 100644 --- a/packages/api/src/domains/navigator/corridor.ts +++ b/packages/api/src/domains/navigator/corridor.ts @@ -1,13 +1,19 @@ import z from 'zod'; -/** A corridor segment on a storey; `x1`/`y1`–`x2`/`y2` is its centre line. */ -export const corridorSchema = z.object({ +/** + * A corridor row as the API returns it: the columns, unconstrained beyond their + * types (see `buildingRowSchema` for why). The base definition; the write shape + * narrows it. + */ +export const corridorRowSchema = z.object({ barrier_free: z.boolean(), - building_id: z.string().min(1), + building_id: z.string(), + createdAt: z.date(), id: z.string(), is_outdoor: z.boolean(), - name: z.string().min(1), + name: z.string(), storey: z.number().int(), + updatedAt: z.date(), width: z.number(), x1: z.number(), x2: z.number(), @@ -15,6 +21,16 @@ export const corridorSchema = z.object({ y2: z.number(), }); +export type CorridorRow = z.infer; + +/** A corridor on a storey of a building. */ +export const corridorSchema = corridorRowSchema + .omit({ createdAt: true, updatedAt: true }) + .extend({ + building_id: z.string().min(1), + name: z.string().min(1), + }); + export const createCorridorSchema = corridorSchema.omit({ id: true }); export const updateCorridorSchema = createCorridorSchema.partial(); @@ -23,25 +39,6 @@ export type Corridor = z.infer; export type CreateCorridorInput = z.infer; export type UpdateCorridorInput = z.infer; -/** A corridor row as the API returns it (see `buildingRowSchema`). */ -export const corridorRowSchema = z.object({ - barrier_free: z.boolean(), - building_id: z.string(), - createdAt: z.date(), - id: z.string(), - is_outdoor: z.boolean(), - name: z.string(), - storey: z.number().int(), - updatedAt: z.date(), - width: z.number(), - x1: z.number(), - x2: z.number(), - y1: z.number(), - y2: z.number(), -}); - -export type CorridorRow = z.infer; - /** Payload of `GET /navigator/corridors`. */ export const corridorsResponseSchema = z.object({ corridors: z.array(corridorRowSchema), diff --git a/packages/api/src/domains/navigator/graph.ts b/packages/api/src/domains/navigator/graph.ts index b281a2d0..1bf6f7a4 100644 --- a/packages/api/src/domains/navigator/graph.ts +++ b/packages/api/src/domains/navigator/graph.ts @@ -1,22 +1,28 @@ import z from 'zod'; -import { buildingSchema } from './building'; -import { classroomSchema } from './classroom'; -import { classroomTypeSchema } from './classroom-type'; -import { corridorSchema } from './corridor'; -import { liftSchema } from './lift'; -import { stairSchema } from './stair'; +import { buildingRowSchema } from './building'; +import { classroomRowSchema } from './classroom'; +import { classroomTypeRowSchema } from './classroom-type'; +import { corridorRowSchema } from './corridor'; +import { liftRowSchema } from './lift'; +import { stairRowSchema } from './stair'; /** * The whole campus in one payload: what the kiosk canvas and the admin editor * both build their scene from. + * + * Built from the `*Row` shapes, not the write shapes: a room the timetable + * imported has a null `type_id`/`capacity` and a building can carry an empty + * name, and the graph has to be readable in exactly the state the import left + * it. oRPC validates this at runtime, so a stricter shape here turns those + * rows into a 500 for every consumer. */ export const fullGraphSchema = z.object({ - buildings: z.array(buildingSchema), - classroom_types: z.array(classroomTypeSchema), - classrooms: z.array(classroomSchema), - corridors: z.array(corridorSchema), - lifts: z.array(liftSchema), - stairs: z.array(stairSchema), + buildings: z.array(buildingRowSchema), + classroom_types: z.array(classroomTypeRowSchema), + classrooms: z.array(classroomRowSchema), + corridors: z.array(corridorRowSchema), + lifts: z.array(liftRowSchema), + stairs: z.array(stairRowSchema), }); export type FullGraph = z.infer; diff --git a/packages/api/src/domains/navigator/lift.ts b/packages/api/src/domains/navigator/lift.ts index 4c6c2206..73440f8b 100644 --- a/packages/api/src/domains/navigator/lift.ts +++ b/packages/api/src/domains/navigator/lift.ts @@ -1,16 +1,32 @@ import z from 'zod'; -/** A lift shaft connecting `min_storey`..`max_storey` in one building. */ -export const liftSchema = z.object({ - building_id: z.string().min(1), +/** + * A lift row as the API returns it: the columns, unconstrained beyond their + * types (see `buildingRowSchema` for why). The base definition; the write shape + * narrows it. + */ +export const liftRowSchema = z.object({ + building_id: z.string(), + createdAt: z.date(), id: z.string(), max_storey: z.number().int(), min_storey: z.number().int(), - name: z.string().min(1), + name: z.string(), + updatedAt: z.date(), x: z.number(), y: z.number(), }); +export type LiftRow = z.infer; + +/** A lift in a building. */ +export const liftSchema = liftRowSchema + .omit({ createdAt: true, updatedAt: true }) + .extend({ + building_id: z.string().min(1), + name: z.string().min(1), + }); + export const createLiftSchema = liftSchema.omit({ id: true }); export const updateLiftSchema = createLiftSchema.partial(); @@ -19,21 +35,6 @@ export type Lift = z.infer; export type CreateLiftInput = z.infer; export type UpdateLiftInput = z.infer; -/** A lift row as the API returns it (see `buildingRowSchema`). */ -export const liftRowSchema = z.object({ - building_id: z.string(), - createdAt: z.date(), - id: z.string(), - max_storey: z.number().int(), - min_storey: z.number().int(), - name: z.string(), - updatedAt: z.date(), - x: z.number(), - y: z.number(), -}); - -export type LiftRow = z.infer; - /** Payload of `GET /navigator/lifts`. */ export const liftsResponseSchema = z.object({ lifts: z.array(liftRowSchema), diff --git a/packages/api/src/domains/navigator/stair.ts b/packages/api/src/domains/navigator/stair.ts index 27b54d29..ee03f8f9 100644 --- a/packages/api/src/domains/navigator/stair.ts +++ b/packages/api/src/domains/navigator/stair.ts @@ -1,18 +1,15 @@ import z from 'zod'; -import { liftSchema } from './lift'; -/** A staircase: a lift shaft that also has an orientation. */ -export const stairSchema = liftSchema.extend({ rotation: z.number() }); - -export const createStairSchema = stairSchema.omit({ id: true }); - -export const updateStairSchema = createStairSchema.partial(); - -export type Stair = z.infer; -export type CreateStairInput = z.infer; -export type UpdateStairInput = z.infer; - -/** A staircase row as the API returns it (see `buildingRowSchema`). */ +/** + * A stair row as the API returns it: the columns, unconstrained beyond their + * types (see `buildingRowSchema` for why). The base definition; the write shape + * narrows it. + * + * The field set is a lift's plus `rotation`, but declared here rather than + * derived from `liftSchema`: the two are separate tables, and deriving one + * domain entity's write shape from another's would couple a change to a lift to + * every stair. + */ export const stairRowSchema = z.object({ building_id: z.string(), createdAt: z.date(), @@ -28,6 +25,22 @@ export const stairRowSchema = z.object({ export type StairRow = z.infer; +/** A stairwell in a building. */ +export const stairSchema = stairRowSchema + .omit({ createdAt: true, updatedAt: true }) + .extend({ + building_id: z.string().min(1), + name: z.string().min(1), + }); + +export const createStairSchema = stairSchema.omit({ id: true }); + +export const updateStairSchema = createStairSchema.partial(); + +export type Stair = z.infer; +export type CreateStairInput = z.infer; +export type UpdateStairInput = z.infer; + /** Payload of `GET /navigator/stairs`. */ export const stairsResponseSchema = z.object({ stairs: z.array(stairRowSchema), diff --git a/packages/api/src/domains/news/announcements.ts b/packages/api/src/domains/news/announcements.ts index a38fa609..a370603d 100644 --- a/packages/api/src/domains/news/announcements.ts +++ b/packages/api/src/domains/news/announcements.ts @@ -22,58 +22,49 @@ export const announcementQuerySchema = paginationSchema.extend({ export type AnnouncementQueryInput = z.infer; -export const announcementCreateSchema = z - .object({ - cohortIds: z.array(z.string()).optional(), - content: blockContentSchema, - /** Shown as a full-screen takeover on the TV kiosks. */ - highlighted: z.boolean().optional(), - /** - * Kiosks the takeover is limited to; none means every kiosk. The marquee - * is unaffected either way. - */ - kioskIds: z.array(z.uuid()).optional(), - /** Keep it off the web app; the kiosks still show it. */ - kioskOnly: z.boolean().optional(), - title: z.string().min(1).optional(), - validFrom: z.coerce.date(), - validUntil: z.coerce.date(), - }) - .refine((data) => data.validUntil >= data.validFrom, { - message: 'validUntil must be on or after validFrom', - path: ['validUntil'], - }); +/** + * The fields an announcement is written with. The update shape is this one + * partial-ed, so a field added here reaches both instead of having to be + * remembered in two lists. + */ +const announcementFieldsSchema = z.object({ + cohortIds: z.array(z.string()).optional(), + content: blockContentSchema, + /** Shown as a full-screen takeover on the TV kiosks. */ + highlighted: z.boolean().optional(), + /** + * Kiosks the takeover is limited to; none means every kiosk. The marquee + * is unaffected either way. + */ + kioskIds: z.array(z.uuid()).optional(), + /** Keep it off the web app; the kiosks still show it. */ + kioskOnly: z.boolean().optional(), + title: z.string().min(1).optional(), + validFrom: z.coerce.date(), + validUntil: z.coerce.date(), +}); + +const validRangeMessage = 'validUntil must be on or after validFrom'; + +export const announcementCreateSchema = announcementFieldsSchema.refine( + (data) => data.validUntil >= data.validFrom, + { message: validRangeMessage, path: ['validUntil'] } +); export type AnnouncementCreateInput = z.infer; -export const announcementUpdateSchema = z - .object({ - cohortIds: z.array(z.string()).optional(), - content: blockContentSchema.optional(), - /** Shown as a full-screen takeover on the TV kiosks. */ - highlighted: z.boolean().optional(), - /** - * Kiosks the takeover is limited to; none means every kiosk. The marquee - * is unaffected either way. - */ - kioskIds: z.array(z.uuid()).optional(), - /** Keep it off the web app; the kiosks still show it. */ - kioskOnly: z.boolean().optional(), - title: z.string().min(1).optional(), - validFrom: z.coerce.date().optional(), - validUntil: z.coerce.date().optional(), - }) +export const announcementUpdateSchema = announcementFieldsSchema + .partial() .refine( + // A patch may carry neither bound, so the ordering only applies when both + // are present. (data) => { if (data.validFrom && data.validUntil) { return data.validUntil >= data.validFrom; } return true; }, - { - message: 'validUntil must be on or after validFrom', - path: ['validUntil'], - } + { message: validRangeMessage, path: ['validUntil'] } ); export type AnnouncementUpdateInput = z.infer; diff --git a/packages/api/src/domains/news/system-messages.ts b/packages/api/src/domains/news/system-messages.ts index 1d48601d..1812749b 100644 --- a/packages/api/src/domains/news/system-messages.ts +++ b/packages/api/src/domains/news/system-messages.ts @@ -1,41 +1,38 @@ import z from 'zod'; import { blockContentSchema, newsAuthorSchema } from './announcements'; -export const dateRangeBodySchema = z - .object({ - cohortIds: z.array(z.string()).optional(), - content: blockContentSchema, - title: z.string().min(1), - validFrom: z.coerce.date(), - validUntil: z.coerce.date(), - }) - .refine((data) => data.validUntil >= data.validFrom, { - message: 'validUntil must be on or after validFrom', - path: ['validUntil'], - }); +/** + * The fields a system message is written with. The update shape is this one + * partial-ed, so a field added here reaches both. + */ +const dateRangeFieldsSchema = z.object({ + cohortIds: z.array(z.string()).optional(), + content: blockContentSchema, + title: z.string().min(1), + validFrom: z.coerce.date(), + validUntil: z.coerce.date(), +}); + +const validRangeMessage = 'validUntil must be on or after validFrom'; + +export const dateRangeBodySchema = dateRangeFieldsSchema.refine( + (data) => data.validUntil >= data.validFrom, + { message: validRangeMessage, path: ['validUntil'] } +); export type DateRangeBodyInput = z.infer; -export const dateRangeUpdateBodySchema = z - .object({ - cohortIds: z.array(z.string()).optional(), - content: blockContentSchema.optional(), - title: z.string().min(1).optional(), - validFrom: z.coerce.date().optional(), - validUntil: z.coerce.date().optional(), - }) - .refine( - (data) => { - if (data.validFrom && data.validUntil) { - return data.validUntil >= data.validFrom; - } - return true; - }, - { - message: 'validUntil must be on or after validFrom', - path: ['validUntil'], +export const dateRangeUpdateBodySchema = dateRangeFieldsSchema.partial().refine( + // A patch may carry neither bound, so the ordering only applies when both + // are present. + (data) => { + if (data.validFrom && data.validUntil) { + return data.validUntil >= data.validFrom; } - ); + return true; + }, + { message: validRangeMessage, path: ['validUntil'] } +); export type DateRangeUpdateBodyInput = z.infer< typeof dateRangeUpdateBodySchema diff --git a/packages/api/src/domains/notifications.ts b/packages/api/src/domains/notifications.ts index 74b8985c..a47e15b6 100644 --- a/packages/api/src/domains/notifications.ts +++ b/packages/api/src/domains/notifications.ts @@ -1,5 +1,25 @@ import z from 'zod'; +/** + * The per-type email/push toggles stored in + * `user_preferences.notification_preferences`. Declared first because + * `updateSettingsSchema` narrows it with `.partial()`: the column has to hold + * a complete record, so a caller may patch a subset but never store one. + */ +export const notificationPreferencesSchema = z.object({ + announcement: z.boolean(), + blogPost: z.boolean(), + channelsEnabled: z.boolean(), + doorlockCardUsed: z.boolean(), + movedLesson: z.boolean(), + substitution: z.boolean(), + systemMessage: z.boolean(), +}); + +export type NotificationPreferences = z.infer< + typeof notificationPreferencesSchema +>; + export const paginationSchema = z.object({ dateFrom: z.string().optional(), dateTo: z.string().optional(), @@ -9,7 +29,10 @@ export const paginationSchema = z.object({ unread: z .enum(['true', 'false']) .optional() - .transform((v) => v === 'true'), + // `undefined` must survive, or zod emits the key for an absent param and + // the handler's `unread !== undefined` check never short-circuits — which + // would filter the inbox to read rows only. + .transform((v) => (v === undefined ? undefined : v === 'true')), }); export type PaginationInput = z.infer; @@ -20,7 +43,7 @@ export type TimetableGroupDisplay = export const updateSettingsSchema = z.object({ language: z.string().optional(), - notificationPreferences: z.record(z.string(), z.unknown()).optional(), + notificationPreferences: notificationPreferencesSchema.partial().optional(), theme: z.string().optional(), timetableClassColors: z.record(z.string(), z.number()).optional(), timetableGroupDisplay: z.enum(timetableGroupDisplayValues).optional(), @@ -105,21 +128,6 @@ export type PreviewTestNotificationInput = z.infer< typeof previewTestNotificationSchema >; -/** The per-type email/push toggles stored in `user_preferences.notification_preferences`. */ -export const notificationPreferencesSchema = z.object({ - announcement: z.boolean(), - blogPost: z.boolean(), - channelsEnabled: z.boolean(), - doorlockCardUsed: z.boolean(), - movedLesson: z.boolean(), - substitution: z.boolean(), - systemMessage: z.boolean(), -}); - -export type NotificationPreferences = z.infer< - typeof notificationPreferencesSchema ->; - /** One `notification` row: a single inbox entry. */ export const notificationRowSchema = z.object({ content: z.string(), diff --git a/packages/api/src/domains/timetable/lesson.ts b/packages/api/src/domains/timetable/lesson.ts index 79ad9ae0..9f8fc5f2 100644 --- a/packages/api/src/domains/timetable/lesson.ts +++ b/packages/api/src/domains/timetable/lesson.ts @@ -1,5 +1,7 @@ import z from 'zod'; +import { periodSelectSchema } from './period'; + export const getLessonsForCohortParamsSchema = z.object({ cohortId: z.uuid(), }); @@ -82,14 +84,6 @@ export const dayDefinitionRowSchema = z.object({ updatedAt: z.date(), }); -/** The period an enriched lesson is scheduled in. */ -export const periodRefSchema = z.object({ - endTime: z.string(), - id: z.string(), - period: z.number(), - startTime: z.string(), -}); - /** The week definition an enriched lesson belongs to. */ export const weekDefinitionRefSchema = z.object({ id: z.string(), @@ -98,25 +92,15 @@ export const weekDefinitionRefSchema = z.object({ weeks: z.string().array(), }); -export const classroomRefSchema = z.object({ - id: z.string(), - name: z.string(), - short: z.string(), -}); - -export const cohortRefSchema = z.object({ - id: z.string(), - name: z.string(), - short: z.string(), -}); - -export const subjectRefSchema = z.object({ - id: z.string(), - name: z.string(), - short: z.string(), -}); - -export const teacherRefSchema = z.object({ +/** + * A lightweight reference to a named entity that carries a short code — used + * for the classroom/cohort/subject/teacher projections on a linked lesson. + * + * One schema rather than four identical ones: they are the same projection of + * different tables, the field name at the use site is what says which, and four + * copies would let a change to one silently diverge from the rest. + */ +export const entityRefSchema = z.object({ id: z.string(), name: z.string(), short: z.string(), @@ -131,16 +115,16 @@ export const groupRefSchema = z.object({ /** Enriched lesson payload of the `timetable.lessons.*` lookups. */ export const enrichedLessonSchema = z.object({ - classrooms: classroomRefSchema.array(), - cohorts: cohortRefSchema.array(), + classrooms: entityRefSchema.array(), + cohorts: entityRefSchema.array(), day: dayDefinitionRowSchema.optional(), groups: groupRefSchema.array(), groupsIds: z.string().array(), id: z.string(), - period: periodRefSchema.nullable(), + period: periodSelectSchema.nullable(), periodsPerWeek: z.number(), - subject: subjectRefSchema.nullable(), - teachers: teacherRefSchema.array(), + subject: entityRefSchema.nullable(), + teachers: entityRefSchema.array(), termDefinitionId: z.string().nullable(), weekDefinition: weekDefinitionRefSchema.nullable(), weeksDefinitionId: z.string(), @@ -168,14 +152,14 @@ export const substitutionCandidatesResultSchema = z.object({ * `#utils/timetable/enrich-lessons` returns. */ export const linkedLessonSchema = z.object({ - classrooms: classroomRefSchema.array(), + classrooms: entityRefSchema.array(), cohorts: z.string().array(), day: dayDefinitionRowSchema.optional(), id: z.string(), - period: periodRefSchema.nullable(), + period: periodSelectSchema.nullable(), periodsPerWeek: z.number(), - subject: subjectRefSchema.nullable(), - teachers: teacherRefSchema.array(), + subject: entityRefSchema.nullable(), + teachers: entityRefSchema.array(), termDefinitionId: z.string().nullable(), weeksDefinitionId: z.string(), }); diff --git a/packages/api/src/domains/timetable/moved-lesson.ts b/packages/api/src/domains/timetable/moved-lesson.ts index b3c54390..0e349f4a 100644 --- a/packages/api/src/domains/timetable/moved-lesson.ts +++ b/packages/api/src/domains/timetable/moved-lesson.ts @@ -1,5 +1,6 @@ import z from 'zod'; +import { classroomRowSchema } from '../navigator/classroom'; import { dayDefinitionRowSchema, linkedLessonSchema } from './lesson'; /** Path parameters for moved lesson endpoints addressed by moved lesson id. */ @@ -18,37 +19,35 @@ export const cohortIdParamsSchema = z.object({ export type CohortIdParamsInput = z.infer; -/** Payload for updating an existing moved lesson. */ +/** + * The lesson-id list both write shapes take. Duplicates would hit the MTM + * primary key and surface as a 500, so they are rejected as the malformed + * request they are. + */ +const lessonIdsSchema = z + .uuid() + .array() + .min(1) + .refine( + (ids) => new Set(ids).size === ids.length, + 'Lesson ids must be unique' + ); + +/** Payload for updating an existing moved lesson. Every field is optional and an + * omitted one means "leave as it is" — the handler reads `undefined` that way. + * `room` is nullable because the column is, and a moved lesson may have no room. + */ export const updateSchema = z.object({ comment: z.string().nullable().optional(), - date: z.coerce.date(), - lessonIds: z.uuid().array().min(1), - room: z.string(), - startingDay: z.uuid(), - startingPeriod: z.uuid(), + date: z.coerce.date().optional(), + lessonIds: lessonIdsSchema.optional(), + room: z.string().nullable().optional(), + startingDay: z.uuid().optional(), + startingPeriod: z.uuid().optional(), }); export type UpdateMovedLessonInput = z.infer; -/** `classroom` row, as returned alongside a moved lesson. */ -export const classroomRowSchema = z.object({ - building_id: z.string(), - capacity: z.number().int().nullable(), - description: z.string(), - id: z.string(), - mapped: z.boolean(), - name: z.string(), - rotation: z.number(), - short: z.string(), - size_x: z.number(), - size_y: z.number(), - size_z: z.number(), - storey: z.number().int(), - type_id: z.string().nullable(), - x: z.number(), - y: z.number(), -}); - /** `period` row, as returned alongside a moved lesson. */ export const periodRowSchema = z.object({ createdAt: z.date(), @@ -82,7 +81,7 @@ export const movedLessonWithRelationsSchema = z.object({ export const createMovedLessonSchema = z.object({ comment: z.string().nullable().optional(), date: z.coerce.date(), - lessonIds: z.uuid().array().min(1), + lessonIds: lessonIdsSchema, room: z.string().nullable().optional(), startingDay: z.string().nullable().optional(), startingPeriod: z.string().nullable().optional(), diff --git a/packages/navigator-3d/src/editor/merge.ts b/packages/navigator-3d/src/editor/merge.ts index 463c953f..3ba07190 100644 --- a/packages/navigator-3d/src/editor/merge.ts +++ b/packages/navigator-3d/src/editor/merge.ts @@ -1,5 +1,8 @@ import type { Building } from '@filcdev/api/domains/navigator/building'; -import type { Classroom } from '@filcdev/api/domains/navigator/classroom'; +import type { + Classroom, + ClassroomRow, +} from '@filcdev/api/domains/navigator/classroom'; import type { Corridor } from '@filcdev/api/domains/navigator/corridor'; import type { FullGraph } from '@filcdev/api/domains/navigator/graph'; import type { Lift } from '@filcdev/api/domains/navigator/lift'; @@ -70,7 +73,7 @@ const defaultCorridor = (): Corridor => ({ * preview. Used to build the live preview node + gizmo anchor without * rebuilding the whole campus. Never mutates the graph. */ export type MergedEntity = - | { kind: 'classroom'; entity: Classroom } + | { kind: 'classroom'; entity: ClassroomRow } | { kind: 'building'; entity: Building } | { kind: 'lift'; entity: Lift } | { kind: 'stairs'; entity: Stair } diff --git a/packages/navigator-3d/src/entities/building-helpers.ts b/packages/navigator-3d/src/entities/building-helpers.ts index 08694a31..9c511d51 100644 --- a/packages/navigator-3d/src/entities/building-helpers.ts +++ b/packages/navigator-3d/src/entities/building-helpers.ts @@ -1,16 +1,16 @@ import type { Building } from '@filcdev/api/domains/navigator/building'; -import type { Classroom } from '@filcdev/api/domains/navigator/classroom'; +import type { ClassroomRow } from '@filcdev/api/domains/navigator/classroom'; import type { FullGraph } from '@filcdev/api/domains/navigator/graph'; import { FLOOR_GAP } from '../scene/storey-resolver'; export function getClassroomsInBuilding( graph: FullGraph, building: Building -): Classroom[] { +): ClassroomRow[] { return graph.classrooms.filter((c) => c.building_id === building.id); } -export function getValidStoreys(rooms: Classroom[]): number[] { +export function getValidStoreys(rooms: ClassroomRow[]): number[] { const set = new Set(); set.add(0); for (const r of rooms) { diff --git a/packages/navigator-3d/src/entities/classroom-node.ts b/packages/navigator-3d/src/entities/classroom-node.ts index 6962da00..cda724fd 100644 --- a/packages/navigator-3d/src/entities/classroom-node.ts +++ b/packages/navigator-3d/src/entities/classroom-node.ts @@ -1,5 +1,5 @@ import type { Building } from '@filcdev/api/domains/navigator/building'; -import type { Classroom } from '@filcdev/api/domains/navigator/classroom'; +import type { ClassroomRow } from '@filcdev/api/domains/navigator/classroom'; import type { FullGraph } from '@filcdev/api/domains/navigator/graph'; // biome-ignore lint/performance/noNamespaceImport: three.js namespace is its public API import * as THREE from 'three'; @@ -14,7 +14,7 @@ const DOOR_COLOR = 0xd9_9a_1f; /** One classroom: fill box + wireframe + door panel. Pivot is the room * center so rotation matches the editor. Only the fill box is pickable. */ export function buildClassroomNode( - c: Classroom, + c: ClassroomRow, b: Building, graph: FullGraph, storeys: StoreyResolver diff --git a/packages/navigator-3d/src/kiosk/palette.ts b/packages/navigator-3d/src/kiosk/palette.ts index 1e83f194..bf9d3f3d 100644 --- a/packages/navigator-3d/src/kiosk/palette.ts +++ b/packages/navigator-3d/src/kiosk/palette.ts @@ -36,7 +36,10 @@ export function kioskStoreyColor(storey: number): number { /** Classroom base color from its type's colorhex, with a neutral * fallback when the type is missing or has no usable color. */ -export function kioskTypeColor(graph: FullGraph, typeId: string): number { +export function kioskTypeColor( + graph: FullGraph, + typeId: string | null +): number { const type = graph.classroom_types.find((t) => t.id === typeId); if (!type?.colorhex) { return KIOSK_COLORS.classroomFallback; diff --git a/packages/navigator-3d/src/kiosk/types.ts b/packages/navigator-3d/src/kiosk/types.ts index 19424f32..3428dd00 100644 --- a/packages/navigator-3d/src/kiosk/types.ts +++ b/packages/navigator-3d/src/kiosk/types.ts @@ -24,8 +24,9 @@ export type KioskNode = { storeyMin?: number; storeyMax?: number; /** Classroom type id — lets the appearance layer highlight by type - * without a graph lookup. Only set for classroom nodes. */ - typeId?: string; + * without a graph lookup. Only set for classroom nodes; null for rooms + * the import left untyped. */ + typeId?: string | null; /** Visibility container — toggled by the appearance layer. */ object: THREE.Object3D; /** Meshes tagged with `userData.app` that the appearance layer recolors. */ diff --git a/packages/navigator-3d/src/path/path-builder.ts b/packages/navigator-3d/src/path/path-builder.ts index 2e3ad98c..4f956d1a 100644 --- a/packages/navigator-3d/src/path/path-builder.ts +++ b/packages/navigator-3d/src/path/path-builder.ts @@ -1,5 +1,5 @@ import type { Building } from '@filcdev/api/domains/navigator/building'; -import type { Classroom } from '@filcdev/api/domains/navigator/classroom'; +import type { ClassroomRow } from '@filcdev/api/domains/navigator/classroom'; import type { Corridor } from '@filcdev/api/domains/navigator/corridor'; import type { FullGraph } from '@filcdev/api/domains/navigator/graph'; import type { Lift } from '@filcdev/api/domains/navigator/lift'; @@ -20,7 +20,7 @@ export class GraphPathBuilder { private barrierFree: boolean; private readonly myLocation: MyLocation | null; - private pointIdToClassroom = new Map(); + private pointIdToClassroom = new Map(); private pointIdToCorridor = new Map(); private pointIdToStoreyConnector = new Map< number, @@ -91,7 +91,7 @@ export class GraphPathBuilder { } private build(): void { - this.pointIdToClassroom = new Map(); + this.pointIdToClassroom = new Map(); this.pointIdToCorridor = new Map(); this.pointIdToStoreyConnector = new Map< number, @@ -413,7 +413,7 @@ export class GraphPathBuilder { return b; } - private classroomDoorPos(c: Classroom): Vec3 { + private classroomDoorPos(c: ClassroomRow): Vec3 { const building = this.getBuilding(c.building_id); const rad = (c.rotation * Math.PI) / 180; // Vector2(0,1).rotated(rad) — same formula as `Classroom.GetDoorOffset`. diff --git a/packages/navigator-3d/src/scene/bounds.ts b/packages/navigator-3d/src/scene/bounds.ts index 9aa4938a..afb1bd7f 100644 --- a/packages/navigator-3d/src/scene/bounds.ts +++ b/packages/navigator-3d/src/scene/bounds.ts @@ -1,5 +1,5 @@ import type { Building } from '@filcdev/api/domains/navigator/building'; -import type { Classroom } from '@filcdev/api/domains/navigator/classroom'; +import type { ClassroomRow } from '@filcdev/api/domains/navigator/classroom'; import type { Corridor } from '@filcdev/api/domains/navigator/corridor'; import type { FullGraph } from '@filcdev/api/domains/navigator/graph'; import { degToRad } from 'three/src/math/MathUtils.js'; @@ -7,7 +7,10 @@ import { degToRad } from 'three/src/math/MathUtils.js'; export type LocalBounds = { x: number; y: number; w: number; h: number }; export type Vector2 = { x: number; y: number }; -function GetRoomBounds(building: Building, classroom: Classroom): LocalBounds { +function GetRoomBounds( + building: Building, + classroom: ClassroomRow +): LocalBounds { const origin = { x: building.x, y: building.y }; const rot = degToRad(classroom.rotation); @@ -100,7 +103,7 @@ function GetCorridorBounds( function GetBoundsByClassrooms( building: Building, - classrooms: Classroom[] + classrooms: ClassroomRow[] ): LocalBounds { if (classrooms.length === 0) { return { h: 0, w: 0, x: 0, y: 0 }; diff --git a/packages/navigator-3d/src/scene/storey-resolver.ts b/packages/navigator-3d/src/scene/storey-resolver.ts index 351b4cf0..f0739bc2 100644 --- a/packages/navigator-3d/src/scene/storey-resolver.ts +++ b/packages/navigator-3d/src/scene/storey-resolver.ts @@ -1,4 +1,4 @@ -import type { Classroom } from '@filcdev/api/domains/navigator/classroom'; +import type { ClassroomRow } from '@filcdev/api/domains/navigator/classroom'; import type { StoreyResolver } from '../types/three/storey-types'; export const FLOOR_GAP = 30; @@ -6,7 +6,9 @@ export const FLOOR_GAP = 30; // Per-building storey stack: storey N+1 sits on the cumulative sum of // storeys 0..N. Storey height = tallest room on that storey (+1m headroom), // capped to FLOOR_HEIGHT. Storeys without rooms fall back to FLOOR_HEIGHT. -export function buildStoreyResolver(classrooms: Classroom[]): StoreyResolver { +export function buildStoreyResolver( + classrooms: ClassroomRow[] +): StoreyResolver { const heightOf = (bid: string, storey: number): number => { let maxH = 0; for (const c of classrooms) { From a10f86929a1ca5a420012c9428bd4c87259db5c3 Mon Sep 17 00:00:00 2001 From: Tamas Vince Date: Fri, 16 Oct 2026 22:12:00 +0200 Subject: [PATCH 31/32] refactor: clear react-doctor findings and make the log usable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit React Doctor: 93 findings (3 errors) -> 13 documented rejections. Gates green (lint, 9/9 typecheck, forced build) and the public timetable exercised in a browser with zero page errors. Errors (tanstack-start-route-property-order) — a tool conflict, not bad routes: - order validateSearch before loader on the three routes that define both - stop biome's useSortedKeys re-alphabetising route options: override it off for apps/iris/src/routes/** with the reason, and replace AGENTS.md's stale "biome sorts the route options" workaround with the real rule Duplication: - extract PageHeader, TableToolbar, TableHeaderRow, DeleteConfirmDialog, RowActions, WifiRowActions, LabeledSelectField, FilterCombobox, and swap every copy of each family onto them; move the wifi filter state type out of the component - wrap the two genuinely eager recharts islands at their consumers with the repo's Lazy boundary, so recharts lands in chart-*.js instead of the entry chunk Behaviour: - reset loading flags in finally (login, welcome) - sandbox the dev notification preview iframe; send-all no longer awaits in a loop - Set-based membership checks instead of array.includes inside iterations - format dates through formatLocalizedDate with the app timezone instead of toLocale* during render - derive the timetable's default selection during render instead of seeding it from an effect (the last no-adjust-state-on-prop-change) - make the import dropzone a real label rather than a clickable div, dropping the three biome-ignore comments it needed - invalidate the notification settings query after unsubscribing - aria-label every icon-only button, adding i18n keys where none existed Rejections kept with evidence: prefer-dynamic-import on eight modules that are already only reached through import(), array-index-as-key on two identity-less PDF rows, and the two rules that demand opposite things at a dialog's preventDefault. Known bug, left for its owner: /legal/tos is linked from the login page and has no route, so it 404s. Logging: - log requests at debug in development — trace sat above the default level, so dev request logging was invisible — and drop the stale "set TRACE" boot hint - document logging in AGENTS.md: structured properties, levels by intent, no silent catches, log and report to Sentry, join on context.requestId, and how to trace a browser-only bug through x-request-id --- .../skills/chronos-route-procedure/SKILL.md | 2 +- AGENTS.md | 29 +- apps/chronos/openapi/chronos-openapi.json | 19766 +++++++++------- apps/chronos/package.json | 1 + apps/chronos/scripts/check-openapi.ts | 171 + apps/chronos/scripts/generate-openapi.ts | 22 +- apps/chronos/scripts/openapi-document.ts | 22 + .../migrations/0025_mean_wildside.sql | 1 + .../migrations/meta/0025_snapshot.json | 4560 ++++ .../database/migrations/meta/_journal.json | 7 + apps/chronos/src/index.ts | 95 +- apps/chronos/src/modules/api-keys/index.ts | 2 +- apps/chronos/src/modules/bug-report/index.ts | 2 +- apps/chronos/src/modules/bug-report/schema.ts | 3 +- apps/chronos/src/modules/cohort/index.ts | 2 +- apps/chronos/src/modules/dashboard/index.ts | 12 +- .../src/modules/doorlock/_notifications.ts | 4 +- apps/chronos/src/modules/doorlock/cards.ts | 8 +- .../src/modules/doorlock/device-socket.ts | 60 +- apps/chronos/src/modules/doorlock/devices.ts | 6 +- apps/chronos/src/modules/doorlock/export.ts | 13 +- apps/chronos/src/modules/doorlock/logs.ts | 73 +- apps/chronos/src/modules/doorlock/self.ts | 6 +- apps/chronos/src/modules/doorlock/stats.ts | 2 +- apps/chronos/src/modules/kiosk/departures.ts | 2 +- apps/chronos/src/modules/kiosk/index.ts | 42 +- apps/chronos/src/modules/kiosk/news.ts | 28 +- apps/chronos/src/modules/kiosk/petrik-news.ts | 8 +- apps/chronos/src/modules/kiosk/schema.ts | 3 +- .../src/modules/navigator/buildings.ts | 8 +- .../src/modules/navigator/classroom-types.ts | 8 +- .../src/modules/navigator/classrooms.ts | 8 +- .../src/modules/navigator/corridors.ts | 8 +- apps/chronos/src/modules/navigator/lifts.ts | 8 +- apps/chronos/src/modules/navigator/stairs.ts | 8 +- .../src/modules/navigator/translations.ts | 12 +- .../src/modules/navigator/utils/errors.ts | 47 +- .../src/modules/navigator/utils/schemas.ts | 95 - .../src/modules/navigator/utils/transfer.ts | 18 - .../chronos/src/modules/news/announcements.ts | 76 +- apps/chronos/src/modules/news/blogs.ts | 4 +- apps/chronos/src/modules/news/schema.ts | 7 +- .../src/modules/news/system-messages.ts | 113 +- apps/chronos/src/modules/news/utils/cohort.ts | 34 +- apps/chronos/src/modules/news/utils/shared.ts | 63 +- .../modules/notifications/notifications.ts | 19 +- .../src/modules/notifications/schema.ts | 23 +- apps/chronos/src/modules/roles/index.ts | 16 +- .../src/modules/timetable/_notifications.ts | 4 +- apps/chronos/src/modules/timetable/cohort.ts | 2 +- apps/chronos/src/modules/timetable/export.ts | 13 +- apps/chronos/src/modules/timetable/groups.ts | 2 +- apps/chronos/src/modules/timetable/index.ts | 8 +- apps/chronos/src/modules/timetable/lesson.ts | 230 +- .../src/modules/timetable/moved-lesson.ts | 19 +- apps/chronos/src/modules/timetable/period.ts | 4 +- apps/chronos/src/modules/timetable/room.ts | 4 +- .../src/modules/timetable/substitution.ts | 13 +- apps/chronos/src/modules/timetable/teacher.ts | 4 +- .../src/modules/timetable/utils/active.ts | 4 +- .../src/modules/timetable/utils/date.ts | 6 - .../modules/timetable/utils/enrich-lessons.ts | 237 +- apps/chronos/src/modules/users/index.ts | 2 +- apps/chronos/src/modules/wifi/admin.ts | 231 +- apps/chronos/src/modules/wifi/self.ts | 32 +- apps/chronos/src/modules/wifi/stats.ts | 18 +- apps/chronos/src/utils/collections.ts | 31 + apps/chronos/src/utils/date.ts | 13 + apps/chronos/src/utils/drizzle.ts | 15 + .../chronos/src/utils/notifications/engine.ts | 59 +- .../src/utils/notifications/initialize.ts | 4 +- .../src/utils/notifications/providers/fcm.ts | 16 +- .../notifications/substitution-teacher.ts | 3 +- apps/chronos/src/utils/postgres.ts | 45 + apps/chronos/src/utils/security.ts | 175 + apps/iris/package.json | 1 + apps/iris/public/locales/en/translation.json | 53 +- apps/iris/public/locales/hu/translation.json | 53 +- .../components/admin/announcements-dialog.tsx | 478 +- .../src/components/admin/api-keys-table.tsx | 18 +- .../admin/delete-confirm-dialog.tsx | 54 + .../src/components/admin/kiosk-dialog.tsx | 942 +- .../components/admin/moved-lesson-dialog.tsx | 521 +- .../admin/navigator/labeled-select-field.tsx | 37 + .../src/components/admin/news-item-dialog.tsx | 8 +- .../iris/src/components/admin/page-header.tsx | 47 + .../iris/src/components/admin/role-dialog.tsx | 19 +- .../iris/src/components/admin/roles-table.tsx | 1 + .../iris/src/components/admin/row-actions.tsx | 47 + apps/iris/src/components/admin/sidebar.tsx | 36 +- .../components/admin/substitution-dialog.tsx | 308 +- .../admin/system-messages-dialog.tsx | 9 +- .../src/components/admin/table-header-row.tsx | 27 + .../src/components/admin/table-toolbar.tsx | 50 + .../admin/timetable-edit-dialog.tsx | 11 - .../admin/timetable-import-dialog.tsx | 14 +- .../iris/src/components/admin/user-dialog.tsx | 5 +- .../iris/src/components/admin/users-table.tsx | 79 +- .../src/components/api-keys/api-keys-card.tsx | 16 +- apps/iris/src/components/confirm-provider.tsx | 107 + .../src/components/doorlock/card-dialog.tsx | 18 +- .../src/components/doorlock/device-dialog.tsx | 26 +- .../doorlock/device-stats-dialog.tsx | 52 +- .../iris/src/components/export-button-pdf.tsx | 2 +- apps/iris/src/components/export-button.tsx | 2 +- apps/iris/src/components/navbar.tsx | 374 +- .../iris/src/components/notification-bell.tsx | 8 +- .../notification-history-dialog.tsx | 9 +- .../components/notification-viewer-dialog.tsx | 12 +- .../src/components/settings-dialog-store.ts | 66 - .../src/components/settings-dialog-store.tsx | 86 + apps/iris/src/components/settings-dialog.tsx | 5 +- .../src/components/settings/general-pane.tsx | 2 +- apps/iris/src/components/subs-view.tsx | 146 +- apps/iris/src/components/subs.tsx | 254 +- .../src/components/system-message-banner.tsx | 2 +- .../src/components/timetable/filter-bar.tsx | 88 +- .../components/timetable/filter-combobox.tsx | 109 + apps/iris/src/components/timetable/grid.tsx | 4 +- apps/iris/src/components/timetable/index.tsx | 550 +- .../src/components/timetable/pdf/document.tsx | 4 +- .../components/timetable/secondary/index.tsx | 2 +- apps/iris/src/components/timetable/types.ts | 14 +- apps/iris/src/components/wifi/row-actions.tsx | 36 + .../iris/src/components/wifi/wifi-dialogs.tsx | 11 +- .../src/components/wifi/wifi-filter-state.ts | 19 + .../iris/src/components/wifi/wifi-filters.tsx | 23 +- apps/iris/src/hooks/admin-api-keys.ts | 9 +- apps/iris/src/hooks/admin-users.ts | 17 +- apps/iris/src/hooks/api-keys.ts | 13 +- apps/iris/src/hooks/bug-reports.ts | 8 +- apps/iris/src/hooks/doorlock-admin.ts | 19 +- apps/iris/src/hooks/kiosks.ts | 19 +- apps/iris/src/hooks/moved-lessons.ts | 19 +- apps/iris/src/hooks/navigator.ts | 27 +- apps/iris/src/hooks/news.ts | 21 +- apps/iris/src/hooks/notifications.ts | 8 + apps/iris/src/hooks/substitutions.ts | 24 +- apps/iris/src/hooks/timetable-groups.ts | 3 +- apps/iris/src/hooks/timetable-public.ts | 66 +- apps/iris/src/hooks/timetable-teachers.ts | 22 +- apps/iris/src/hooks/timetables-admin.ts | 19 +- apps/iris/src/hooks/types.ts | 5 + apps/iris/src/hooks/use-has-permission.ts | 7 +- apps/iris/src/hooks/use-sortable.ts | 121 + apps/iris/src/hooks/wifi-admin.ts | 23 +- apps/iris/src/hooks/wifi.ts | 13 +- apps/iris/src/router.tsx | 6 +- apps/iris/src/routes/__root.tsx | 22 +- .../src/routes/_private/admin/api-keys.tsx | 8 +- .../src/routes/_private/admin/bug-reports.tsx | 564 +- .../_private/admin/dev/notifications.tsx | 25 +- .../routes/_private/admin/doorlock/cards.tsx | 419 +- .../_private/admin/doorlock/devices.tsx | 390 +- .../routes/_private/admin/doorlock/index.tsx | 15 +- .../routes/_private/admin/doorlock/logs.tsx | 408 +- .../iris/src/routes/_private/admin/kiosks.tsx | 29 +- .../_private/admin/navigator/buildings.tsx | 32 +- .../admin/navigator/classroom-types.tsx | 29 +- .../_private/admin/navigator/classrooms.tsx | 582 +- .../_private/admin/navigator/corridors.tsx | 483 +- .../routes/_private/admin/navigator/lifts.tsx | 60 +- .../_private/admin/navigator/stairs.tsx | 60 +- .../_private/admin/navigator/translations.tsx | 30 +- .../_private/admin/news/announcements.tsx | 465 +- .../_private/admin/news/system-messages.tsx | 197 +- apps/iris/src/routes/_private/admin/roles.tsx | 29 +- .../_private/admin/timetable/manage.tsx | 44 +- .../admin/timetable/moved-lessons.tsx | 431 +- .../admin/timetable/substitutions.tsx | 526 +- .../_private/admin/timetable/teachers.tsx | 105 +- apps/iris/src/routes/_private/admin/users.tsx | 10 +- .../src/routes/_private/admin/wifi/index.tsx | 44 +- .../src/routes/_private/admin/wifi/nas.tsx | 76 +- .../_private/admin/wifi/speed-profiles.tsx | 340 +- .../src/routes/_private/admin/wifi/users.tsx | 817 +- apps/iris/src/routes/_private/cards/index.tsx | 2 +- apps/iris/src/routes/_private/wifi.tsx | 5 +- apps/iris/src/routes/_public/index.tsx | 15 +- apps/iris/src/routes/_public/policy.tsx | 25 +- apps/iris/src/routes/auth/login.tsx | 10 +- apps/iris/src/routes/auth/welcome.tsx | 24 +- apps/iris/src/utils/confirm.ts | 8 - apps/iris/src/utils/ssr-query.tsx | 64 - apps/iris/vite.config.ts | 10 +- .../src/components/navigator/search-panel.tsx | 30 +- .../src/components/tv/departure-container.tsx | 2 +- apps/kiosk/src/hooks/tv.ts | 20 +- biome.jsonc | 17 + bun.lock | 6 + docs/architecture.md | 2 +- package.json | 1 + packages/api/src/contract/admin-api-keys.ts | 9 - packages/api/src/contract/bug-report.ts | 11 - packages/api/src/contract/cohort.ts | 7 +- packages/api/src/contract/dashboard.ts | 3 - packages/api/src/contract/doorlock.ts | 71 +- packages/api/src/contract/health.ts | 3 - packages/api/src/contract/kiosk.ts | 37 +- packages/api/src/contract/navigator.ts | 176 +- packages/api/src/contract/news.ts | 65 +- packages/api/src/contract/notifications.ts | 14 +- packages/api/src/contract/ping.ts | 6 - packages/api/src/contract/roles.ts | 4 - packages/api/src/contract/route.ts | 37 +- packages/api/src/contract/timetable/import.ts | 1 - .../api/src/contract/timetable/lessons.ts | 32 +- .../src/contract/timetable/moved-lessons.ts | 38 +- packages/api/src/contract/timetable/people.ts | 51 +- .../src/contract/timetable/substitutions.ts | 34 +- .../api/src/contract/timetable/timetables.ts | 27 +- packages/api/src/contract/users.ts | 6 - packages/api/src/contract/wifi.ts | 127 +- packages/api/src/domains/admin-api-keys.ts | 6 +- packages/api/src/domains/bug-report.ts | 10 +- packages/api/src/domains/cohort.ts | 6 +- packages/api/src/domains/doorlock/cards.ts | 16 +- packages/api/src/domains/doorlock/devices.ts | 10 +- packages/api/src/domains/doorlock/logs.ts | 7 +- packages/api/src/domains/doorlock/self.ts | 6 - packages/api/src/domains/doorlock/stats.ts | 11 +- packages/api/src/domains/kiosk/config.ts | 19 +- packages/api/src/domains/kiosk/crud.ts | 6 +- packages/api/src/domains/kiosk/heartbeat.ts | 3 +- packages/api/src/domains/kiosk/responses.ts | 94 +- packages/api/src/domains/list.ts | 10 + .../api/src/domains/navigator/building.ts | 10 - .../src/domains/navigator/classroom-type.ts | 10 - .../api/src/domains/navigator/classroom.ts | 10 - .../api/src/domains/navigator/corridor.ts | 10 - packages/api/src/domains/navigator/lift.ts | 10 - packages/api/src/domains/navigator/stair.ts | 10 - .../api/src/domains/navigator/translation.ts | 18 +- .../api/src/domains/news/announcements.ts | 46 +- packages/api/src/domains/news/blogs.ts | 14 +- .../api/src/domains/news/system-messages.ts | 21 +- packages/api/src/domains/notifications.ts | 12 +- packages/api/src/domains/roles.ts | 9 +- packages/api/src/domains/timetable/groups.ts | 2 +- packages/api/src/domains/timetable/lesson.ts | 52 +- .../api/src/domains/timetable/moved-lesson.ts | 16 +- packages/api/src/domains/timetable/period.ts | 4 +- packages/api/src/domains/timetable/room.ts | 6 +- packages/api/src/domains/timetable/subject.ts | 2 +- .../api/src/domains/timetable/substitution.ts | 32 +- packages/api/src/domains/timetable/teacher.ts | 2 +- .../api/src/domains/timetable/timetables.ts | 12 +- packages/api/src/domains/users.ts | 10 +- packages/api/src/domains/wifi/self.ts | 7 - packages/api/src/errors.ts | 10 + packages/timetable-import/src/import.ts | 13 +- packages/timetable-import/src/store.ts | 36 +- packages/ui/src/components/input-group.tsx | 20 +- 253 files changed, 23780 insertions(+), 16123 deletions(-) create mode 100644 apps/chronos/scripts/check-openapi.ts create mode 100644 apps/chronos/scripts/openapi-document.ts create mode 100644 apps/chronos/src/database/migrations/0025_mean_wildside.sql create mode 100644 apps/chronos/src/database/migrations/meta/0025_snapshot.json delete mode 100644 apps/chronos/src/modules/timetable/utils/date.ts create mode 100644 apps/chronos/src/utils/collections.ts create mode 100644 apps/chronos/src/utils/date.ts create mode 100644 apps/chronos/src/utils/drizzle.ts create mode 100644 apps/chronos/src/utils/postgres.ts create mode 100644 apps/chronos/src/utils/security.ts create mode 100644 apps/iris/src/components/admin/delete-confirm-dialog.tsx create mode 100644 apps/iris/src/components/admin/navigator/labeled-select-field.tsx create mode 100644 apps/iris/src/components/admin/page-header.tsx create mode 100644 apps/iris/src/components/admin/row-actions.tsx create mode 100644 apps/iris/src/components/admin/table-header-row.tsx create mode 100644 apps/iris/src/components/admin/table-toolbar.tsx create mode 100644 apps/iris/src/components/confirm-provider.tsx delete mode 100644 apps/iris/src/components/settings-dialog-store.ts create mode 100644 apps/iris/src/components/settings-dialog-store.tsx create mode 100644 apps/iris/src/components/timetable/filter-combobox.tsx create mode 100644 apps/iris/src/components/wifi/row-actions.tsx create mode 100644 apps/iris/src/components/wifi/wifi-filter-state.ts create mode 100644 apps/iris/src/hooks/types.ts create mode 100644 apps/iris/src/hooks/use-sortable.ts delete mode 100644 apps/iris/src/utils/confirm.ts delete mode 100644 apps/iris/src/utils/ssr-query.tsx create mode 100644 packages/api/src/domains/list.ts diff --git a/.agents/skills/chronos-route-procedure/SKILL.md b/.agents/skills/chronos-route-procedure/SKILL.md index 54af5db4..af7f7dc4 100644 --- a/.agents/skills/chronos-route-procedure/SKILL.md +++ b/.agents/skills/chronos-route-procedure/SKILL.md @@ -4,7 +4,7 @@ description: Procedures for adding or changing Chronos API endpoints — oRPC co --- # Chronos Route Procedure -- Endpoints are oRPC procedures, not Hono routes. The contract is the single source of truth: declare one procedure per endpoint in `packages/api/src/contract/.ts` as `oc.route(filcRoute({...})).input(...).output(...)`. `filcRoute` carries `method`, `path`, `operationId`, `tags`, `successStatus` and the `x-filc_*` values mergen's Kotlin client reads — copy the pattern from `packages/api/src/contract/users.ts`. +- Endpoints are oRPC procedures, not Hono routes. The contract is the single source of truth: declare one procedure per endpoint in `packages/api/src/contract/.ts` as `oc.route(filcRoute({...})).input(...).output(...)`. `filcRoute` carries `method`, `path`, `operationId`, `tags`, `successStatus` and `auth` — copy the pattern from `packages/api/src/contract/users.ts`. There are no `x-filc_*` extensions to fill in: authentication is the document's `sessionAuth` scheme, and the client derives everything else from the schemas. - `.output(...)` is mandatory on every procedure. `.input(...)` is ONE flat object — path params plus query (GET) or the JSON body (POST/PUT/PATCH) — with the same field names as the wire. - Implement handlers in the feature folder under `apps/chronos/src/modules/`: handler files, a `_router.ts` exporting a plain object literal mirroring the contract nesting, `schema.ts`, and a `_module.ts` manifest when the feature owns jobs or notifications. Handler shape: `base...handler(async ({ input, context }) => payload)` where `base` is `implement(appContract)` from `apps/chronos/src/orpc.ts` — see `apps/chronos/src/modules/ping/index.ts`. - Register every handler in the feature's `_router.ts`; the full contract is assembled once via `base.router({...})` in `apps/chronos/src/router.ts`. A missing, extra or mistyped procedure fails `tsc` rather than 404ing at runtime. diff --git a/AGENTS.md b/AGENTS.md index 7c46118f..96666259 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,15 +28,31 @@ - Bun 1.4.0 ignores `[run] bun = true` from bunfig.toml when spawning package binaries. Scripts that need the Bun runtime (vite, drizzle-kit) must invoke them via `bun --bun `; see the `dev`, `build`, and `db:*` scripts. - Every API error response carries a machine-readable `code` from the shared error map in [packages/api/src/errors.ts](packages/api/src/errors.ts), installed on the whole contract with `oc.errors(apiErrors)`. The map is what gives a code its HTTP status, its default message and its documented error response, so a new code is added there and nowhere else. Throw through the helpers in Chronos' `#utils/http` (`notFound`, `conflict`, `badRequest`, `forbidden`, `unauthorized`, `badGateway`, `serviceUnavailable`) or the `errors.(...)` factories a handler receives; clients read the code off the thrown `ORPCError`. +## Logging And Observability + +The app log is the first place to look when tracking a bug, and the record we leave for whoever debugs it next. Treat it as a golden source: log generously on the paths you touch, at a level that makes the detail available, because the difference between "it broke" and "this call returned 401 at 12:04 with this mac" is entirely in what we chose to emit. + +- Use LogTape with one category per feature: `const logger = getLogger(['chronos', '', …])` (see [apps/chronos/src/utils/logger.ts](apps/chronos/src/utils/logger.ts) and the nesting in [apps/chronos/src/modules/wifi](apps/chronos/src/modules/wifi)). Pass values as the structured properties, never interpolated into the message — `logger.debug('radius auth rejected', { mac, nasIp, reason })` stays filterable by field and greppable in the JSON-lines production output. +- Choose the level by intent, and always leave the step-by-step detail in place: `trace`/`debug` for the stages of a code path, `info` for lifecycle and request summaries, `warning` for something recoverable and unexpected, `error` for what needs a human. `CHRONOS_LOG_LEVEL` defaults to `info` in the schema and to `debug` in [apps/chronos/.env.example](apps/chronos/.env.example), so debug (and trace) lines are expected on the paths you are working on — never strip them as noise, and never hide them behind a temporary flag or a comment-out. +- A catch block that swallows without logging is a bug we will never diagnose: no `catch {}`, no `.catch(() => null)` outside a deliberate, commented, recoverable case. Log the error object with the ids you were working with, at `debug`/`warning` when it is handled and `error` when it is not. The auth middleware's bare `.catch(() => null)` in this PR is the cautionary tale — it turned infrastructure failures into anonymous requests. +- Log and report: `logger.error` writes to stdout, which is the only production error channel today because `captureException` is used once in the whole backend. When a failure reaches a user or deserves an alert, send it to Sentry as well (`captureException` from `@sentry/bun`, cf. [apps/chronos/src/modules/wifi/utils/cleanup.ts](apps/chronos/src/modules/wifi/utils/cleanup.ts)). +- Every Chronos request carries an id: [apps/chronos/src/index.ts](apps/chronos/src/index.ts) takes the client's `x-request-id` or mints one, exposes it as `context.requestId`, logs it on the request line, and echoes it back in the `x-request-id` response header. Put `context.requestId` in the properties of anything you log inside a handler — that is what joins your line to the request, to the error boundary's line, and to a client report. The request line itself is `debug` in development and `info` in production. +- Non-`ORPCError` throws are logged with `message`, `stack` and the route context, then surfaced as a generic `INTERNAL` error, so a throw is never lost — that log is the only trace of the original failure in production, where the client sees the generic message. Make the properties there worth reading. +- Iris has no structured logger: client-side diagnosis is Sentry-based and sampled by [apps/iris/src/utils/telemetry.ts](apps/iris/src/utils/telemetry.ts). When a bug only reproduces in the browser, read the failing request's `x-request-id` response header and match it to the Chronos lines rather than guessing at timings. +- Sentry user context is attached per request by `resolveCaller` ([apps/chronos/src/middleware/auth.ts](apps/chronos/src/middleware/auth.ts)); keep the `setSentryUser(null)` on the anonymous path so an event is never attributed to the previous caller. + ## Shared API Contracts (`@filcdev/api`) -- The contract is the single source of truth: `packages/api/src/contract/.ts` declares one procedure per endpoint as `oc.route(filcRoute({...})).input(...).output(...)`, and [packages/api/src/contract/index.ts](packages/api/src/contract/index.ts) mounts them all in `appContract`. Operation metadata (`method`, `path`, `operationId`, `tags`, `successStatus`, `x-filc_*`) comes from `filcRoute` and is what the generated OpenAPI document — and mergen's Kotlin client — reads, so it is not free to change. +- The contract is the single source of truth: `packages/api/src/contract/.ts` declares one procedure per endpoint as `oc.route(filcRoute({...})).input(...).output(...)`, and [packages/api/src/contract/index.ts](packages/api/src/contract/index.ts) mounts them all in `appContract`. Operation metadata (`method`, `path`, `operationId`, `tags`, `successStatus`) comes from `filcRoute` and is what the generated OpenAPI document — and the mobile client generated from it — reads, so it is not free to change. `auth: true` marks the operation with the document's `sessionAuth` scheme; there are no custom extensions. - `.output(...)` is mandatory on every procedure: it types the apps and produces the external clients' models. `.input(...)` is ONE flat object — path params plus query (GET) or JSON body (POST/PUT/PATCH), same field names as the wire, no nesting. A file response is `z.file()`, a multipart upload takes the file as a `z.file()` field. - Response payloads are the unwrapped value; there is no `{ success, data }` envelope. Drizzle `timestamp` columns are `z.date()` (the RPC transport round-trips `Date` objects and the OpenAPI serializer still emits `format: date-time`), `date()` columns are `z.string()`. Deletes answer `{ id }` — or `{ ok: true }` when there is no id — with status 200; nothing answers 204. +- Three shapes, no others. A **list** endpoint answers `{ items, total }`, built with `listResponse(item)` from [packages/api/src/domains/list.ts](packages/api/src/domains/list.ts) — `total` is the unpaged count, or the array's own length when the endpoint returns every row (never add a count query for it). A **single entity** answers the entity itself, never `{ : entity }`. A **delete** answers `{ id }`. `bun run openapi:check` in [apps/chronos](apps/chronos) fails on a delete that returns a row and on a stale generated document. +- Every id is `z.uuid()` when its column is one: entity `id`s, the `*Id` fields referencing them, and the `{ id }` a delete answers with. The schema has to be true about what the column can hold, so a `text` column that happens to store uuid values — a few timetable tables — stays `z.string()`. - Wire schemas (request bodies, path/query params, hand-written response payloads, entity rows) belong in `packages/api/src/domains/...`; apps and Chronos import them from `@filcdev/api/domains/`. Entity schemas are hand-written there — `packages/api` must not import drizzle-orm or anything from `apps/chronos`, so Drizzle-derived schemas never cross the boundary. - Permission strings are canonical constants from `@filcdev/api/permissions`. Never inline `'resource:action'` literals in middleware or UI gating; they are also stored in the `roles` table, so renaming requires a data migration. - Frontend API calls go through the client built in `apps//src/utils/orpc.ts` from `createApiClient` (`@filcdev/api/client`), which returns `{ client, orpc }`: `client` for imperative calls, `orpc` for the TanStack Query utilities (`queryOptions`, `mutationOptions`, `key`). Errors arrive as an `ORPCError` — branch on `isDefinedError(error) && error.code === '…'` or on `error.status`. -- After any contract change, regenerate the OpenAPI document: `bun run openapi:generate` in [apps/chronos](apps/chronos) rewrites `openapi/chronos-openapi.json`, which is exactly the document `/api/doc/openapi.json` serves — both come from the options in [apps/chronos/src/utils/openapi-spec.ts](apps/chronos/src/utils/openapi-spec.ts). External clients consume that file as generated (mergen copies it to its own `openapi/filc-openapi.json`); nothing post-processes a per-consumer variant. +- After any contract change, regenerate the OpenAPI document: `bun run openapi:generate` in [apps/chronos](apps/chronos) rewrites `openapi/chronos-openapi.json`, which is exactly the document `/api/doc/openapi.json` serves — both come from the options in [apps/chronos/src/utils/openapi-spec.ts](apps/chronos/src/utils/openapi-spec.ts). External clients consume that file as generated (mergen copies it to its own `openapi/filc-openapi.json`); nothing post-processes a per-consumer variant. Commit the regenerated file: `bun run openapi:check` fails when it is stale, and also when an `x-filc_type_name` hint names a response field the wire no longer carries. +- The document carries **no custom extensions**. It used to add `x-filc_group_name`, `x-filc_type_name` and `x-filc_auth` for a bespoke Kotlin codegen that has been abandoned; authentication is the standard `security` scheme and everything else a client needs is derived from the schemas. Do not reintroduce a per-route extension payload. ## Chronos Backend @@ -67,13 +83,17 @@ - Iris is a TanStack Start app: [apps/iris/src/router.tsx](apps/iris/src/router.tsx) builds a router (and QueryClient) per request, [apps/iris/src/client.tsx](apps/iris/src/client.tsx) hydrates it, [apps/iris/src/routes/__root.tsx](apps/iris/src/routes/__root.tsx) renders the document shell. Production is served by `srvx` over the build output (`srvx serve --prod -s ../client dist/server/server.js`); there is no custom server file. `bun run build && bun run start` serves everything from one process; the platform proxy (Traefik) routes `/api` to Chronos. - `ssr` defaults to `true`; the authenticated tree is `ssr: 'data-only'` on [apps/iris/src/routes/_private/route.tsx](apps/iris/src/routes/_private/route.tsx), which every descendant inherits. -- A route's `loader` prefetches with `prefetch(() => context.queryClient.ensureQueryData(orpc...queryOptions({ input })))` from [apps/iris/src/utils/orpc.ts](apps/iris/src/utils/orpc.ts) — the same call the component's hook makes, so hydration reads the cache instead of refetching. `prefetch` swallows a rejected prefetch because every screen is reachable without a session. Read search params from `location.searchStr` in a loader rather than adding `loaderDeps`: biome sorts the route options, and a `loaderDeps` above `validateSearch` pins the search type to `{}`. +- A route's `loader` prefetches with `prefetch(() => context.queryClient.ensureQueryData(orpc...queryOptions({ input })))` from [apps/iris/src/utils/orpc.ts](apps/iris/src/utils/orpc.ts) — the same call the component's hook makes, so hydration reads the cache instead of refetching. `prefetch` swallows a rejected prefetch because every screen is reachable without a session. +- Keep TanStack Router's canonical route-property order: `params`/`validateSearch` → `loaderDeps` → `context`/`beforeLoad` → `loader` → `head`. Each step contributes to the types the next one reads, so a `validateSearch` after `loader`/`loaderDeps` silently widens the search type to `{}`. Biome's `useSortedKeys` is off for `apps/iris/src/routes/**` for that reason (see the override in [biome.jsonc](biome.jsonc)) and react-doctor's `tanstack-start-route-property-order` guards the order — never hand-sort those keys to satisfy the formatter. Reading params off `location.searchStr` in a loader and declaring `loaderDeps` are both fine now that the order holds. +- A route module exports its `Route` and nothing else: any second export (a search schema, a helper) breaks Fast Refresh, so shared schemas live beside the data they describe (e.g. `@/hooks/timetable-public`) and the route imports them. +- An icon-only `Button` carries an `aria-label` built from an existing `t(...)` key. - Per-request reads go through `createIsomorphicFn` thunks in [apps/iris/src/utils/request.ts](apps/iris/src/utils/request.ts) and [apps/iris/src/utils/i18n.ts](apps/iris/src/utils/i18n.ts) — the chain yields a value, so wrap it in an arrow to keep it callable per request. The oRPC client in [apps/iris/src/utils/orpc.ts](apps/iris/src/utils/orpc.ts) does the same, resolving the API origin and forwarding the request headers on the server. - The session of a server render is read once in the root route and provided through `AuthSessionProvider` from `@filcdev/auth/client`; better-auth's own client only fetches a session in the browser. - Keep a heavy island behind [apps/iris/src/components/lazy.tsx](apps/iris/src/components/lazy.tsx) when it only appears behind a dialog or a closed panel; `load` must be a module-level arrow. - Keep user-facing text in `t(...)` and update both locale trees under [apps/iris/public/locales/en](apps/iris/public/locales/en) and [apps/iris/public/locales/hu](apps/iris/public/locales/hu). - TanStack Form is the default form pattern: `useForm`, `useStore(form.store, selector)`, `{(field) => ...}` (see [apps/iris/src/components/doorlock/card-dialog.tsx](apps/iris/src/components/doorlock/card-dialog.tsx)). -- `form.reset(values)` takes raw values, not `{ values }`; `form.reset` and `form.setFieldValue` are not stable `useEffect` dependencies, so omit them from dependency arrays. +- `form.reset(values)` takes raw values, not `{ values }`. Do **not** re-seed a form from a prop inside a `useEffect`: React Doctor flags it (`no-adjust-state-on-prop-change`), the effect re-runs on every render whenever one of its dependencies is recreated (`exhaustive-deps`), and when the defaults differ per call — a generated token or an array `key` — that is an endless render loop that takes the whole route down, since no route has an `errorComponent`. `kiosk-dialog` and `device-dialog` crashed this way. +- Instead let the dialog remount: the parent passes a `key` that changes when it opens and when the edited item changes — ``key={`${open ? 'open' : 'closed'}:${item?.id ?? 'new'}`}`` — so `useForm({ defaultValues })` seeds itself on mount and reopening discards edits, exactly as `form.reset` did. Keep those defaults stable regardless: never mint `crypto.randomUUID()` inside `initialState`/`toFormValues`. - Base UI dropdown wrappers use `onClick`, not Radix-style `onSelect`, unless the local component exposes a different API. - [packages/ui/src/components/chart.tsx](packages/ui/src/components/chart.tsx) already owns `ResponsiveContainer`; do not wrap chart children in another one. - Keep public timetable filter state in TanStack Router search params instead of duplicating it in unrelated local state. @@ -88,6 +108,7 @@ ### Dialogs And Forms - Dialogs are self-contained: they call domain mutation hooks directly and close themselves via `onOpenChange(false)` from their `onSaved` callback. Do not pass payload-submit callbacks (`onSubmit`) into dialogs. Reference shape: [apps/iris/src/components/admin/substitution-dialog.tsx](apps/iris/src/components/admin/substitution-dialog.tsx). +- A dialog's submit handler calls `event.preventDefault()` as its first statement, before `form.handleSubmit()` / a manual branch. Two react-doctor rules point at that spot and only one can win: `tanstack-form-on-submit-requires-prevent-default` requires the call, `no-prevent-default` reports a form whose handler calls it. A dialog is client-only (Base UI portals it, nothing renders while closed), so the preventDefault stays and the `no-prevent-default` report is expected — as is a `tanstack-form-on-submit-requires-prevent-default` report when the handler branches after the call, because the detector treats any conditional as "not guaranteed". Neither is cleared by restructuring the handler to hide it. - Follow the form idiom of [apps/iris/src/components/admin/user-dialog.tsx](apps/iris/src/components/admin/user-dialog.tsx): form near the top of the component, reactive slices via `useStore(form.store, selector)`, fields via ``. Plain `useState`+`onChange` forms are not acceptable for new or migrated code. - Reuse validation schemas from [apps/iris/src/utils/form-schemas.ts](apps/iris/src/utils/form-schemas.ts) when available, or the shared domain contracts from `@filcdev/api` when validating against backend wire shapes. If a schema becomes shared by multiple dialogs, move it there instead of copying validation logic. - Extend shared dialog prop types ([apps/iris/src/components/admin/admin.types.ts](apps/iris/src/components/admin/admin.types.ts), [apps/iris/src/components/doorlock/doorlock.types.ts](apps/iris/src/components/doorlock/doorlock.types.ts)) instead of defining near-duplicate props. diff --git a/apps/chronos/openapi/chronos-openapi.json b/apps/chronos/openapi/chronos-openapi.json index 047f9298..86f328a6 100644 --- a/apps/chronos/openapi/chronos-openapi.json +++ b/apps/chronos/openapi/chronos-openapi.json @@ -246,9 +246,6 @@ } } }, - "x-filc_group_name": "API keys", - "x-filc_type_name": "@nodata", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -584,9 +581,6 @@ } } }, - "x-filc_group_name": "API keys", - "x-filc_type_name": "@unit AdminApiKey", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -653,7 +647,7 @@ "schema": { "type": "object", "properties": { - "apiKeys": { + "items": { "type": "array", "items": { "type": "object", @@ -764,7 +758,7 @@ } }, "required": [ - "apiKeys", + "items", "total" ] } @@ -954,9 +948,6 @@ } } }, - "x-filc_group_name": "API keys", - "x-filc_type_name": "@unit AdminApiKeyListResponse @field(.apiKeys, List) @field(.total, Int)", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -989,7 +980,85 @@ "type": "string", "maxLength": 64 }, - "additionalProperties": {} + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": {} + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": {} + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + } + ] + } }, "page": { "type": "string", @@ -1212,9 +1281,6 @@ } } }, - "x-filc_group_name": "BugReport", - "x-filc_type_name": "@unit BugReport", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -1306,7 +1372,7 @@ "schema": { "type": "object", "properties": { - "reports": { + "items": { "type": "array", "items": { "type": "object", @@ -1323,7 +1389,106 @@ "type": "string", "format": "uuid" }, - "metadata": {}, + "metadata": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + } + ] + }, + { + "type": "null" + } + ] + }, "page": { "anyOf": [ { @@ -1371,6 +1536,7 @@ "createdAt", "description", "id", + "metadata", "page", "reporterEmail", "reporterId", @@ -1385,7 +1551,7 @@ } }, "required": [ - "reports", + "items", "total" ] } @@ -1575,9 +1741,6 @@ } } }, - "x-filc_group_name": "BugReport", - "x-filc_type_name": "@unit BugReportListResponse @field(.reports, List)", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -1806,9 +1969,6 @@ } } }, - "x-filc_group_name": "BugReport", - "x-filc_type_name": "@nodata", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -1878,7 +2038,106 @@ "type": "string", "format": "uuid" }, - "metadata": {}, + "metadata": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + } + ] + }, + { + "type": "null" + } + ] + }, "page": { "anyOf": [ { @@ -1926,6 +2185,7 @@ "createdAt", "description", "id", + "metadata", "page", "reporterEmail", "reporterId", @@ -2120,9 +2380,6 @@ } } }, - "x-filc_group_name": "BugReport", - "x-filc_type_name": "@unit BugReport", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -2143,62 +2400,77 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "classroomIds": { - "anyOf": [ - { - "type": "array", - "items": { - "type": "string" - } + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "classroomIds": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "short": { - "type": "string" - }, - "teacherId": { - "anyOf": [ - { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { "type": "string" }, - { - "type": "null" - } - ] - }, - "timetableId": { - "anyOf": [ - { + "short": { "type": "string" }, - { - "type": "null" + "teacherId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + }, + "timetableId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] } + }, + "required": [ + "classroomIds", + "id", + "name", + "short", + "teacherId", + "timetableId" ] } }, - "required": [ - "classroomIds", - "id", - "name", - "short", - "teacherId", - "timetableId" - ] - } + "total": { + "type": "number" + } + }, + "required": [ + "items", + "total" + ] } } } @@ -2385,9 +2657,7 @@ } } } - }, - "x-filc_group_name": "Cohort", - "x-filc_type_name": "@listof Cohort" + } } }, "/dashboard/stats": { @@ -2685,9 +2955,7 @@ } } } - }, - "x-filc_group_name": "Dashboard", - "x-filc_type_name": "@unit DashboardStatsResponse" + } } }, "/doorlock/cards": { @@ -2757,119 +3025,111 @@ "schema": { "type": "object", "properties": { - "card": { - "type": "object", - "properties": { - "authorizedDevices": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "name": { - "type": "string" - } - }, - "required": [ - "id", - "name" - ] + "authorizedDevices": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" } }, - "cardData": { - "type": "string" - }, - "createdAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "enabled": { - "type": "boolean" - }, - "frozen": { - "type": "boolean" - }, - "id": { - "type": "string", - "format": "uuid" - }, - "name": { - "type": "string" - }, - "owner": { - "anyOf": [ - { - "type": "object", - "properties": { - "email": { - "type": "string" - }, - "id": { - "type": "string", - "format": "uuid" - }, - "name": { + "required": [ + "id", + "name" + ] + } + }, + "cardData": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "enabled": { + "type": "boolean" + }, + "frozen": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "owner": { + "anyOf": [ + { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "nickname": { + "anyOf": [ + { "type": "string" }, - "nickname": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] + { + "type": "null" } - }, - "required": [ - "email", - "id", - "name", - "nickname" ] - }, - { - "type": "null" } + }, + "required": [ + "email", + "id", + "name", + "nickname" ] }, - "updatedAt": { + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "userId": { + "anyOf": [ + { "type": "string", - "format": "date-time", - "x-native-type": "date" + "format": "uuid" }, - "userId": { - "anyOf": [ - { - "type": "string", - "format": "uuid" - }, - { - "type": "null" - } - ] + { + "type": "null" } - }, - "required": [ - "authorizedDevices", - "cardData", - "createdAt", - "enabled", - "frozen", - "id", - "name", - "updatedAt", - "userId" ] } }, "required": [ - "card" + "authorizedDevices", + "cardData", + "createdAt", + "enabled", + "frozen", + "id", + "name", + "updatedAt", + "userId" ] } } @@ -3058,9 +3318,6 @@ } } }, - "x-filc_group_name": "Doorlock", - "x-filc_type_name": "@unit CardResponse @field(.card, Card)", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -3081,7 +3338,7 @@ "schema": { "type": "object", "properties": { - "cards": { + "items": { "type": "array", "items": { "type": "object", @@ -3193,10 +3450,14 @@ "userId" ] } + }, + "total": { + "type": "number" } }, "required": [ - "cards" + "items", + "total" ] } } @@ -3385,9 +3646,6 @@ } } }, - "x-filc_group_name": "Doorlock", - "x-filc_type_name": "@unit CardListResponse @field(.cards, List)", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -3616,9 +3874,6 @@ } } }, - "x-filc_group_name": "Doorlock", - "x-filc_type_name": "@nodata", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -3697,119 +3952,111 @@ "schema": { "type": "object", "properties": { - "card": { - "type": "object", - "properties": { - "authorizedDevices": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "name": { - "type": "string" - } - }, - "required": [ - "id", - "name" - ] + "authorizedDevices": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" } }, - "cardData": { - "type": "string" - }, - "createdAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "enabled": { - "type": "boolean" - }, - "frozen": { - "type": "boolean" - }, - "id": { - "type": "string", - "format": "uuid" - }, - "name": { - "type": "string" - }, - "owner": { - "anyOf": [ - { - "type": "object", - "properties": { - "email": { - "type": "string" - }, - "id": { - "type": "string", - "format": "uuid" - }, - "name": { + "required": [ + "id", + "name" + ] + } + }, + "cardData": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "enabled": { + "type": "boolean" + }, + "frozen": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "owner": { + "anyOf": [ + { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "nickname": { + "anyOf": [ + { "type": "string" }, - "nickname": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] + { + "type": "null" } - }, - "required": [ - "email", - "id", - "name", - "nickname" ] - }, - { - "type": "null" } + }, + "required": [ + "email", + "id", + "name", + "nickname" ] }, - "updatedAt": { + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "userId": { + "anyOf": [ + { "type": "string", - "format": "date-time", - "x-native-type": "date" + "format": "uuid" }, - "userId": { - "anyOf": [ - { - "type": "string", - "format": "uuid" - }, - { - "type": "null" - } - ] + { + "type": "null" } - }, - "required": [ - "authorizedDevices", - "cardData", - "createdAt", - "enabled", - "frozen", - "id", - "name", - "updatedAt", - "userId" ] } }, "required": [ - "card" + "authorizedDevices", + "cardData", + "createdAt", + "enabled", + "frozen", + "id", + "name", + "updatedAt", + "userId" ] } } @@ -3998,9 +4245,6 @@ } } }, - "x-filc_group_name": "Doorlock", - "x-filc_type_name": "@unit CardResponse @field(.card, Card)", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -4023,7 +4267,7 @@ "schema": { "type": "object", "properties": { - "users": { + "items": { "type": "array", "items": { "type": "object", @@ -4056,10 +4300,14 @@ "nickname" ] } + }, + "total": { + "type": "number" } }, "required": [ - "users" + "items", + "total" ] } } @@ -4248,9 +4496,6 @@ } } }, - "x-filc_group_name": "Doorlock", - "x-filc_type_name": "@unit DoorlockUserListResponse @field(.users, List)", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -4318,63 +4563,55 @@ "schema": { "type": "object", "properties": { - "device": { - "type": "object", - "properties": { - "apiToken": { + "apiToken": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "lastResetReason": { + "anyOf": [ + { "type": "string" }, - "createdAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "id": { - "type": "string", - "format": "uuid" - }, - "lastResetReason": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "location": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "name": { + { + "type": "null" + } + ] + }, + "location": { + "anyOf": [ + { "type": "string" }, - "updatedAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" + { + "type": "null" } - }, - "required": [ - "apiToken", - "createdAt", - "id", - "lastResetReason", - "location", - "name", - "updatedAt" ] + }, + "name": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" } }, "required": [ - "device" + "apiToken", + "createdAt", + "id", + "lastResetReason", + "location", + "name", + "updatedAt" ] } } @@ -4563,9 +4800,6 @@ } } }, - "x-filc_group_name": "Doorlock", - "x-filc_type_name": "@unit DeviceResponse @field(.device, Device)", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -4586,7 +4820,7 @@ "schema": { "type": "object", "properties": { - "devices": { + "items": { "type": "array", "items": { "type": "object", @@ -4642,10 +4876,14 @@ "updatedAt" ] } + }, + "total": { + "type": "number" } }, "required": [ - "devices" + "items", + "total" ] } } @@ -4834,9 +5072,6 @@ } } }, - "x-filc_group_name": "Doorlock", - "x-filc_type_name": "@unit DeviceListResponse @field(.devices, List)", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -5065,9 +5300,6 @@ } } }, - "x-filc_group_name": "Doorlock", - "x-filc_type_name": "@nodata", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -5144,63 +5376,55 @@ "schema": { "type": "object", "properties": { - "device": { - "type": "object", - "properties": { - "apiToken": { + "apiToken": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "lastResetReason": { + "anyOf": [ + { "type": "string" }, - "createdAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "id": { - "type": "string", - "format": "uuid" - }, - "lastResetReason": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "location": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "name": { + { + "type": "null" + } + ] + }, + "location": { + "anyOf": [ + { "type": "string" }, - "updatedAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" + { + "type": "null" } - }, - "required": [ - "apiToken", - "createdAt", - "id", - "lastResetReason", - "location", - "name", - "updatedAt" ] + }, + "name": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" } }, "required": [ - "device" + "apiToken", + "createdAt", + "id", + "lastResetReason", + "location", + "name", + "updatedAt" ] } } @@ -5389,9 +5613,6 @@ } } }, - "x-filc_group_name": "Doorlock", - "x-filc_type_name": "@unit DeviceResponse @field(.device, Device)", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -5423,112 +5644,120 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "deviceMeta": { + "type": "object", + "properties": { + "stats": { + "type": "array", + "items": { "type": "object", "properties": { - "debug": { + "deviceMeta": { "type": "object", "properties": { - "deviceState": { - "enum": [ - "booting", - "error", - "idle", - "updating" - ], - "type": "string" - }, - "errors": { + "debug": { "type": "object", "properties": { - "db": { - "type": "boolean" - }, - "nfc": { - "type": "boolean" - }, - "ota": { - "type": "boolean" + "deviceState": { + "enum": [ + "booting", + "error", + "idle", + "updating" + ], + "type": "string" }, - "sd": { - "type": "boolean" + "errors": { + "type": "object", + "properties": { + "db": { + "type": "boolean" + }, + "nfc": { + "type": "boolean" + }, + "ota": { + "type": "boolean" + }, + "sd": { + "type": "boolean" + }, + "wifi": { + "type": "boolean" + } + }, + "required": [ + "db", + "nfc", + "ota", + "sd", + "wifi" + ] }, - "wifi": { - "type": "boolean" + "lastResetReason": { + "type": "string" } }, "required": [ - "db", - "nfc", - "ota", - "sd", - "wifi" + "deviceState", + "errors", + "lastResetReason" ] }, - "lastResetReason": { + "fwVersion": { "type": "string" - } - }, - "required": [ - "deviceState", - "errors", - "lastResetReason" - ] - }, - "fwVersion": { - "type": "string" - }, - "ramFree": { - "type": "number" - }, - "storage": { - "type": "object", - "properties": { - "total": { + }, + "ramFree": { "type": "number" }, - "used": { + "storage": { + "type": "object", + "properties": { + "total": { + "type": "number" + }, + "used": { + "type": "number" + } + }, + "required": [ + "total", + "used" + ] + }, + "uptime": { "type": "number" } }, "required": [ - "total", - "used" + "debug", + "fwVersion", + "ramFree", + "storage", + "uptime" ] }, - "uptime": { - "type": "number" + "id": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "timestamp": { + "type": "string", + "format": "date-time", + "x-native-type": "date" } }, "required": [ - "debug", - "fwVersion", - "ramFree", - "storage", - "uptime" + "deviceMeta", + "id", + "timestamp" ] - }, - "id": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - "timestamp": { - "type": "string", - "format": "date-time", - "x-native-type": "date" } - }, - "required": [ - "deviceMeta", - "id", - "timestamp" - ] - } + } + }, + "required": [ + "stats" + ] } } } @@ -5716,9 +5945,6 @@ } } }, - "x-filc_group_name": "Doorlock", - "x-filc_type_name": "@unit DeviceStatsResponse @field(.stats, List)", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -6475,9 +6701,6 @@ } } }, - "x-filc_group_name": "Doorlock", - "x-filc_type_name": "Export audit log entries as CSV", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -6586,7 +6809,7 @@ "schema": { "type": "object", "properties": { - "logs": { + "items": { "type": "array", "items": { "type": "object", @@ -6739,10 +6962,14 @@ "userId" ] } + }, + "total": { + "type": "number" } }, "required": [ - "logs" + "items", + "total" ] } } @@ -6931,9 +7158,6 @@ } } }, - "x-filc_group_name": "Doorlock", - "x-filc_type_name": "@unit DoorlockLogListResponse @field(.logs, List)", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -6983,76 +7207,68 @@ "schema": { "type": "object", "properties": { - "log": { - "type": "object", - "properties": { - "buttonPressed": { - "type": "boolean" - }, - "cardData": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "cardId": { - "anyOf": [ - { - "type": "string", - "format": "uuid" - }, - { - "type": "null" - } - ] + "buttonPressed": { + "type": "boolean" + }, + "cardData": { + "anyOf": [ + { + "type": "string" }, - "deviceId": { + { + "type": "null" + } + ] + }, + "cardId": { + "anyOf": [ + { "type": "string", "format": "uuid" }, - "id": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - "result": { - "type": "boolean" - }, - "timestamp": { + { + "type": "null" + } + ] + }, + "deviceId": { + "type": "string", + "format": "uuid" + }, + "id": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "result": { + "type": "boolean" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "userId": { + "anyOf": [ + { "type": "string", - "format": "date-time", - "x-native-type": "date" + "format": "uuid" }, - "userId": { - "anyOf": [ - { - "type": "string", - "format": "uuid" - }, - { - "type": "null" - } - ] + { + "type": "null" } - }, - "required": [ - "buttonPressed", - "cardData", - "cardId", - "deviceId", - "id", - "result", - "timestamp", - "userId" ] } }, "required": [ - "log" + "buttonPressed", + "cardData", + "cardId", + "deviceId", + "id", + "result", + "timestamp", + "userId" ] } } @@ -7241,9 +7457,6 @@ } } }, - "x-filc_group_name": "Doorlock", - "x-filc_type_name": "@unit DoorlockActivationResponse @field(.log, AuditLog)", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -7266,7 +7479,7 @@ "schema": { "type": "object", "properties": { - "cards": { + "items": { "type": "array", "items": { "type": "object", @@ -7378,10 +7591,14 @@ "userId" ] } + }, + "total": { + "type": "number" } }, "required": [ - "cards" + "items", + "total" ] } } @@ -7570,9 +7787,6 @@ } } }, - "x-filc_group_name": "Doorlock", - "x-filc_type_name": "@unit CardListResponse @field(.cards, List)", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -7624,119 +7838,111 @@ "schema": { "type": "object", "properties": { - "card": { - "type": "object", - "properties": { - "authorizedDevices": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "name": { - "type": "string" - } - }, - "required": [ - "id", - "name" - ] + "authorizedDevices": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" } }, - "cardData": { - "type": "string" - }, - "createdAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "enabled": { - "type": "boolean" - }, - "frozen": { - "type": "boolean" - }, - "id": { - "type": "string", - "format": "uuid" - }, - "name": { - "type": "string" - }, - "owner": { - "anyOf": [ - { - "type": "object", - "properties": { - "email": { - "type": "string" - }, - "id": { - "type": "string", - "format": "uuid" - }, - "name": { + "required": [ + "id", + "name" + ] + } + }, + "cardData": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "enabled": { + "type": "boolean" + }, + "frozen": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "owner": { + "anyOf": [ + { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "nickname": { + "anyOf": [ + { "type": "string" }, - "nickname": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] + { + "type": "null" } - }, - "required": [ - "email", - "id", - "name", - "nickname" ] - }, - { - "type": "null" } + }, + "required": [ + "email", + "id", + "name", + "nickname" ] }, - "updatedAt": { + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "userId": { + "anyOf": [ + { "type": "string", - "format": "date-time", - "x-native-type": "date" + "format": "uuid" }, - "userId": { - "anyOf": [ - { - "type": "string", - "format": "uuid" - }, - { - "type": "null" - } - ] + { + "type": "null" } - }, - "required": [ - "authorizedDevices", - "cardData", - "createdAt", - "enabled", - "frozen", - "id", - "name", - "updatedAt", - "userId" ] } }, "required": [ - "card" + "authorizedDevices", + "cardData", + "createdAt", + "enabled", + "frozen", + "id", + "name", + "updatedAt", + "userId" ] } } @@ -7925,9 +8131,6 @@ } } }, - "x-filc_group_name": "Doorlock", - "x-filc_type_name": "@unit CardResponse @field(.card, Card)", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -7984,7 +8187,8 @@ "maximum": 9007199254740991 }, "id": { - "type": "string" + "type": "string", + "format": "uuid" }, "name": { "anyOf": [ @@ -8230,9 +8434,6 @@ } } }, - "x-filc_group_name": "Doorlock", - "x-filc_type_name": "@unit DoorlockStatsResponse @field(.stats, DoorlockStats)", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -8452,9 +8653,7 @@ } } } - }, - "x-filc_group_name": "Health", - "x-filc_type_name": "@unit HealthResponse" + } } }, "/kiosk": { @@ -8471,7 +8670,85 @@ "schema": { "type": "object", "properties": { - "config": {}, + "config": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": {} + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": {} + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + } + ] + }, "enabled": { "type": "boolean" }, @@ -8509,85 +8786,170 @@ "schema": { "type": "object", "properties": { - "kiosk": { - "type": "object", - "properties": { - "appVersion": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "config": {}, - "createdAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "enabled": { - "type": "boolean" - }, - "id": { - "type": "string", - "format": "uuid" + "appVersion": { + "anyOf": [ + { + "type": "string" }, - "kind": { + { + "type": "null" + } + ] + }, + "config": { + "anyOf": [ + { "type": "string" }, - "lastSeenAt": { - "anyOf": [ - { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - { - "type": "null" - } - ] + { + "type": "number" }, - "lastSeenIp": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] + { + "type": "boolean" }, - "machineId": { - "type": "string" + { + "type": "null" }, - "name": { - "type": "string" + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } }, - "updatedAt": { + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + } + ] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "kind": { + "type": "string" + }, + "lastSeenAt": { + "anyOf": [ + { "type": "string", "format": "date-time", "x-native-type": "date" + }, + { + "type": "null" } - }, - "required": [ - "appVersion", - "createdAt", - "enabled", - "id", - "kind", - "lastSeenAt", - "lastSeenIp", - "machineId", - "name", - "updatedAt" ] + }, + "lastSeenIp": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "machineId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" } }, "required": [ - "kiosk" + "appVersion", + "config", + "createdAt", + "enabled", + "id", + "kind", + "lastSeenAt", + "lastSeenIp", + "machineId", + "name", + "updatedAt" ] } } @@ -8776,9 +9138,6 @@ } } }, - "x-filc_group_name": "Kiosk", - "x-filc_type_name": "@unit KioskResponse @field(.kiosk, Kiosk)", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -8799,7 +9158,7 @@ "schema": { "type": "object", "properties": { - "kiosks": { + "items": { "type": "array", "items": { "type": "object", @@ -8814,7 +9173,99 @@ } ] }, - "config": {}, + "config": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + } + ] + }, "createdAt": { "type": "string", "format": "date-time", @@ -8866,6 +9317,7 @@ }, "required": [ "appVersion", + "config", "createdAt", "enabled", "id", @@ -8877,10 +9329,14 @@ "updatedAt" ] } + }, + "total": { + "type": "number" } }, "required": [ - "kiosks" + "items", + "total" ] } } @@ -9069,9 +9525,6 @@ } } }, - "x-filc_group_name": "Kiosk", - "x-filc_type_name": "@unit KioskListResponse @field(.kiosks, List)", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -9105,85 +9558,13 @@ "schema": { "type": "object", "properties": { - "kiosk": { - "type": "object", - "properties": { - "appVersion": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "config": {}, - "createdAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "enabled": { - "type": "boolean" - }, - "id": { - "type": "string", - "format": "uuid" - }, - "kind": { - "type": "string" - }, - "lastSeenAt": { - "anyOf": [ - { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - { - "type": "null" - } - ] - }, - "lastSeenIp": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "machineId": { - "type": "string" - }, - "name": { - "type": "string" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - } - }, - "required": [ - "appVersion", - "createdAt", - "enabled", - "id", - "kind", - "lastSeenAt", - "lastSeenIp", - "machineId", - "name", - "updatedAt" - ] + "id": { + "type": "string", + "format": "uuid" } }, "required": [ - "kiosk" + "id" ] } } @@ -9372,9 +9753,6 @@ } } }, - "x-filc_group_name": "Kiosk", - "x-filc_type_name": "@unit KioskResponse @field(.kiosk, Kiosk)", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -9405,7 +9783,85 @@ "schema": { "type": "object", "properties": { - "config": {}, + "config": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": {} + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": {} + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + } + ] + }, "enabled": { "type": "boolean" }, @@ -9433,85 +9889,170 @@ "schema": { "type": "object", "properties": { - "kiosk": { - "type": "object", - "properties": { - "appVersion": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "config": {}, - "createdAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "enabled": { - "type": "boolean" - }, - "id": { - "type": "string", - "format": "uuid" + "appVersion": { + "anyOf": [ + { + "type": "string" }, - "kind": { + { + "type": "null" + } + ] + }, + "config": { + "anyOf": [ + { "type": "string" }, - "lastSeenAt": { - "anyOf": [ - { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - { - "type": "null" - } - ] + { + "type": "number" }, - "lastSeenIp": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] + { + "type": "boolean" }, - "machineId": { - "type": "string" + { + "type": "null" }, - "name": { - "type": "string" + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } }, - "updatedAt": { + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + } + ] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "kind": { + "type": "string" + }, + "lastSeenAt": { + "anyOf": [ + { "type": "string", "format": "date-time", "x-native-type": "date" + }, + { + "type": "null" } - }, - "required": [ - "appVersion", - "createdAt", - "enabled", - "id", - "kind", - "lastSeenAt", - "lastSeenIp", - "machineId", - "name", - "updatedAt" ] + }, + "lastSeenIp": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "machineId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" } }, "required": [ - "kiosk" + "appVersion", + "config", + "createdAt", + "enabled", + "id", + "kind", + "lastSeenAt", + "lastSeenIp", + "machineId", + "name", + "updatedAt" ] } } @@ -9700,9 +10241,6 @@ } } }, - "x-filc_group_name": "Kiosk", - "x-filc_type_name": "@unit KioskResponse @field(.kiosk, Kiosk)", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -9784,7 +10322,7 @@ "schema": { "type": "object", "properties": { - "departures": { + "items": { "type": "array", "items": { "anyOf": [ @@ -9812,10 +10350,14 @@ } ] } + }, + "total": { + "type": "number" } }, "required": [ - "departures" + "items", + "total" ] } } @@ -10003,9 +10545,7 @@ } } } - }, - "x-filc_group_name": "Kiosk", - "x-filc_type_name": "@unit KioskDeparturesResponse @field(.departures, List)" + } } }, "/kiosk/heartbeat": { @@ -10122,12 +10662,105 @@ "name": { "type": "string" }, - "config": {} + "config": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + } + ] + } }, "required": [ "id", "kind", - "name" + "name", + "config" ] }, "registered": { @@ -10327,9 +10960,7 @@ } } } - }, - "x-filc_group_name": "Kiosk", - "x-filc_type_name": "@unit KioskHeartbeatResponse @field(.kiosk, Kiosk)" + } } }, "/kiosk/news/{id}/image": { @@ -10544,9 +11175,7 @@ } } } - }, - "x-filc_group_name": "Kiosk", - "x-filc_type_name": "@unit KioskNewsImage" + } } }, "/kiosk/news": { @@ -10564,7 +11193,7 @@ "schema": { "type": "object", "properties": { - "announcements": { + "items": { "type": "array", "items": { "type": "object", @@ -10592,7 +11221,8 @@ "kioskIds": { "type": "array", "items": { - "type": "string" + "type": "string", + "format": "uuid" } }, "title": { @@ -10620,10 +11250,14 @@ "validUntil" ] } + }, + "total": { + "type": "number" } }, "required": [ - "announcements" + "items", + "total" ] } } @@ -10811,9 +11445,7 @@ } } } - }, - "x-filc_group_name": "Kiosk", - "x-filc_type_name": "@unit KioskNewsResponse @field(.announcements, List)" + } } }, "/kiosk/petrik-news": { @@ -10879,10 +11511,14 @@ "url" ] } + }, + "total": { + "type": "number" } }, "required": [ - "items" + "items", + "total" ] } } @@ -11070,9 +11706,7 @@ } } } - }, - "x-filc_group_name": "Kiosk", - "x-filc_type_name": "@unit KioskPetrikNewsResponse" + } } }, "/kiosk/weather": { @@ -11295,9 +11929,7 @@ } } } - }, - "x-filc_group_name": "Kiosk", - "x-filc_type_name": "@unit KioskWeatherResponse" + } } }, "/navigator/buildings": { @@ -11346,40 +11978,32 @@ "schema": { "type": "object", "properties": { - "building": { - "type": "object", - "properties": { - "description": { - "type": "string" - }, - "id": { - "type": "string" - }, - "mapped": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "x": { - "type": "number" - }, - "y": { - "type": "number" - } - }, - "required": [ - "description", - "id", - "mapped", - "name", - "x", - "y" - ] + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mapped": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" } }, "required": [ - "building" + "description", + "id", + "mapped", + "name", + "x", + "y" ] } } @@ -11568,9 +12192,6 @@ } } }, - "x-filc_group_name": "Navigator", - "x-filc_type_name": "@unit Building", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -11591,7 +12212,7 @@ "schema": { "type": "object", "properties": { - "buildings": { + "items": { "type": "array", "items": { "type": "object", @@ -11624,10 +12245,14 @@ "y" ] } + }, + "total": { + "type": "number" } }, "required": [ - "buildings" + "items", + "total" ] } } @@ -11815,9 +12440,7 @@ } } } - }, - "x-filc_group_name": "Navigator", - "x-filc_type_name": "@listof Building" + } } }, "/navigator/buildings/{id}": { @@ -11846,40 +12469,12 @@ "schema": { "type": "object", "properties": { - "building": { - "type": "object", - "properties": { - "description": { - "type": "string" - }, - "id": { - "type": "string" - }, - "mapped": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "x": { - "type": "number" - }, - "y": { - "type": "number" - } - }, - "required": [ - "description", - "id", - "mapped", - "name", - "x", - "y" - ] + "id": { + "type": "string" } }, "required": [ - "building" + "id" ] } } @@ -12068,9 +12663,6 @@ } } }, - "x-filc_group_name": "Navigator", - "x-filc_type_name": "@unit Building", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -12127,125 +12719,117 @@ "schema": { "type": "object", "properties": { - "building": { - "type": "object", - "properties": { - "description": { - "type": "string" - }, - "id": { - "type": "string" - }, - "mapped": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "x": { - "type": "number" - }, - "y": { - "type": "number" - } - }, - "required": [ - "description", - "id", - "mapped", - "name", - "x", - "y" - ] - } - }, - "required": [ - "building" - ] - } - } - } - }, - "400": { - "description": "400", - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "type": "string" - }, - "message": { + "description": { "type": "string" }, - "status": { - "type": "integer" - } - }, - "required": [ - "code", - "message", - "status" - ], - "type": "object" - } - } - } - }, - "401": { - "description": "401", - "content": { - "application/json": { - "schema": { - "properties": { - "code": { + "id": { "type": "string" }, - "message": { - "type": "string" + "mapped": { + "type": "boolean" }, - "status": { - "type": "integer" - } - }, - "required": [ - "code", - "message", - "status" - ], - "type": "object" - } - } - } - }, - "403": { - "description": "403", - "content": { - "application/json": { - "schema": { - "properties": { - "code": { + "name": { "type": "string" }, - "message": { - "type": "string" + "x": { + "type": "number" }, - "status": { - "type": "integer" + "y": { + "type": "number" } }, "required": [ - "code", - "message", - "status" - ], - "type": "object" + "description", + "id", + "mapped", + "name", + "x", + "y" + ] } } } }, - "404": { - "description": "404", + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", "content": { "application/json": { "schema": { @@ -12349,9 +12933,6 @@ } } }, - "x-filc_group_name": "Navigator", - "x-filc_type_name": "@unit Building", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -12447,94 +13028,86 @@ "schema": { "type": "object", "properties": { - "classroom": { - "type": "object", - "properties": { - "building_id": { - "type": "string" - }, - "capacity": { - "anyOf": [ - { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - { - "type": "null" - } - ] - }, - "description": { - "type": "string" - }, - "id": { - "type": "string" - }, - "mapped": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "rotation": { - "type": "number" - }, - "short": { - "type": "string" - }, - "size_x": { - "type": "number" - }, - "size_y": { - "type": "number" - }, - "size_z": { - "type": "number" - }, - "storey": { + "building_id": { + "type": "string" + }, + "capacity": { + "anyOf": [ + { "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, - "type_id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "x": { - "type": "number" + { + "type": "null" + } + ] + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mapped": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "rotation": { + "type": "number" + }, + "short": { + "type": "string" + }, + "size_x": { + "type": "number" + }, + "size_y": { + "type": "number" + }, + "size_z": { + "type": "number" + }, + "storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "type_id": { + "anyOf": [ + { + "type": "string" }, - "y": { - "type": "number" + { + "type": "null" } - }, - "required": [ - "building_id", - "capacity", - "description", - "id", - "mapped", - "name", - "rotation", - "short", - "size_x", - "size_y", - "size_z", - "storey", - "type_id", - "x", - "y" ] + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" } }, "required": [ - "classroom" + "building_id", + "capacity", + "description", + "id", + "mapped", + "name", + "rotation", + "short", + "size_x", + "size_y", + "size_z", + "storey", + "type_id", + "x", + "y" ] } } @@ -12723,9 +13296,6 @@ } } }, - "x-filc_group_name": "Navigator", - "x-filc_type_name": "@unit Classroom", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -12746,7 +13316,7 @@ "schema": { "type": "object", "properties": { - "classrooms": { + "items": { "type": "array", "items": { "type": "object", @@ -12833,10 +13403,14 @@ "y" ] } + }, + "total": { + "type": "number" } }, "required": [ - "classrooms" + "items", + "total" ] } } @@ -13024,9 +13598,7 @@ } } } - }, - "x-filc_group_name": "Navigator", - "x-filc_type_name": "@listof Classroom" + } } }, "/navigator/classrooms/{id}": { @@ -13055,94 +13627,12 @@ "schema": { "type": "object", "properties": { - "classroom": { - "type": "object", - "properties": { - "building_id": { - "type": "string" - }, - "capacity": { - "anyOf": [ - { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - { - "type": "null" - } - ] - }, - "description": { - "type": "string" - }, - "id": { - "type": "string" - }, - "mapped": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "rotation": { - "type": "number" - }, - "short": { - "type": "string" - }, - "size_x": { - "type": "number" - }, - "size_y": { - "type": "number" - }, - "size_z": { - "type": "number" - }, - "storey": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - "type_id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "x": { - "type": "number" - }, - "y": { - "type": "number" - } - }, - "required": [ - "building_id", - "capacity", - "description", - "id", - "mapped", - "name", - "rotation", - "short", - "size_x", - "size_y", - "size_z", - "storey", - "type_id", - "x", - "y" - ] + "id": { + "type": "string" } }, "required": [ - "classroom" + "id" ] } } @@ -13331,9 +13821,6 @@ } } }, - "x-filc_group_name": "Navigator", - "x-filc_type_name": "@unit Classroom", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -13423,94 +13910,86 @@ "schema": { "type": "object", "properties": { - "classroom": { - "type": "object", - "properties": { - "building_id": { - "type": "string" - }, - "capacity": { - "anyOf": [ - { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - { - "type": "null" - } - ] - }, - "description": { - "type": "string" - }, - "id": { - "type": "string" - }, - "mapped": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "rotation": { - "type": "number" - }, - "short": { - "type": "string" - }, - "size_x": { - "type": "number" - }, - "size_y": { - "type": "number" - }, - "size_z": { - "type": "number" - }, - "storey": { + "building_id": { + "type": "string" + }, + "capacity": { + "anyOf": [ + { "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, - "type_id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "x": { - "type": "number" + { + "type": "null" + } + ] + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mapped": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "rotation": { + "type": "number" + }, + "short": { + "type": "string" + }, + "size_x": { + "type": "number" + }, + "size_y": { + "type": "number" + }, + "size_z": { + "type": "number" + }, + "storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "type_id": { + "anyOf": [ + { + "type": "string" }, - "y": { - "type": "number" + { + "type": "null" } - }, - "required": [ - "building_id", - "capacity", - "description", - "id", - "mapped", - "name", - "rotation", - "short", - "size_x", - "size_y", - "size_z", - "storey", - "type_id", - "x", - "y" ] + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" } }, "required": [ - "classroom" + "building_id", + "capacity", + "description", + "id", + "mapped", + "name", + "rotation", + "short", + "size_x", + "size_y", + "size_z", + "storey", + "type_id", + "x", + "y" ] } } @@ -13699,9 +14178,6 @@ } } }, - "x-filc_group_name": "Navigator", - "x-filc_type_name": "@unit Classroom", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -13748,40 +14224,32 @@ "schema": { "type": "object", "properties": { - "classroom_type": { - "type": "object", - "properties": { - "colorhex": { - "type": "string" - }, - "createdAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - } - }, - "required": [ - "colorhex", - "createdAt", - "id", - "name", - "updatedAt" - ] + "colorhex": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" } }, "required": [ - "classroom_type" + "colorhex", + "createdAt", + "id", + "name", + "updatedAt" ] } } @@ -13970,9 +14438,6 @@ } } }, - "x-filc_group_name": "Navigator", - "x-filc_type_name": "@unit ClassroomType", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -13993,7 +14458,7 @@ "schema": { "type": "object", "properties": { - "classroom_types": { + "items": { "type": "array", "items": { "type": "object", @@ -14026,10 +14491,14 @@ "updatedAt" ] } + }, + "total": { + "type": "number" } }, "required": [ - "classroom_types" + "items", + "total" ] } } @@ -14217,9 +14686,7 @@ } } } - }, - "x-filc_group_name": "Navigator", - "x-filc_type_name": "@listof ClassroomType" + } } }, "/navigator/classroom-types/{id}": { @@ -14248,40 +14715,12 @@ "schema": { "type": "object", "properties": { - "classroom_type": { - "type": "object", - "properties": { - "colorhex": { - "type": "string" - }, - "createdAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - } - }, - "required": [ - "colorhex", - "createdAt", - "id", - "name", - "updatedAt" - ] + "id": { + "type": "string" } }, "required": [ - "classroom_type" + "id" ] } } @@ -14470,9 +14909,6 @@ } } }, - "x-filc_group_name": "Navigator", - "x-filc_type_name": "@unit ClassroomType", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -14524,40 +14960,32 @@ "schema": { "type": "object", "properties": { - "classroom_type": { - "type": "object", - "properties": { - "colorhex": { - "type": "string" - }, - "createdAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - } - }, - "required": [ - "colorhex", - "createdAt", - "id", - "name", - "updatedAt" - ] + "colorhex": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" } }, "required": [ - "classroom_type" + "colorhex", + "createdAt", + "id", + "name", + "updatedAt" ] } } @@ -14746,9 +15174,6 @@ } } }, - "x-filc_group_name": "Navigator", - "x-filc_type_name": "@unit ClassroomType", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -14829,159 +15254,151 @@ "schema": { "type": "object", "properties": { - "corridor": { - "type": "object", - "properties": { - "barrier_free": { - "type": "boolean" - }, - "building_id": { - "type": "string" - }, - "createdAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "id": { - "type": "string" - }, - "is_outdoor": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "storey": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "width": { - "type": "number" - }, - "x1": { - "type": "number" - }, - "x2": { - "type": "number" - }, - "y1": { - "type": "number" - }, - "y2": { - "type": "number" - } - }, - "required": [ - "barrier_free", - "building_id", - "createdAt", - "id", - "is_outdoor", - "name", - "storey", - "updatedAt", - "width", - "x1", - "x2", - "y1", - "y2" - ] - } - }, - "required": [ - "corridor" - ] - } - } - } - }, - "400": { - "description": "400", - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "type": "string" + "barrier_free": { + "type": "boolean" }, - "message": { + "building_id": { "type": "string" }, - "status": { - "type": "integer" - } - }, - "required": [ - "code", - "message", - "status" - ], - "type": "object" - } - } - } - }, - "401": { - "description": "401", - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "type": "string" + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" }, - "message": { + "id": { "type": "string" }, - "status": { - "type": "integer" - } - }, - "required": [ - "code", - "message", - "status" - ], - "type": "object" - } - } - } - }, - "403": { - "description": "403", - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "type": "string" + "is_outdoor": { + "type": "boolean" }, - "message": { + "name": { "type": "string" }, - "status": { - "type": "integer" + "storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "width": { + "type": "number" + }, + "x1": { + "type": "number" + }, + "x2": { + "type": "number" + }, + "y1": { + "type": "number" + }, + "y2": { + "type": "number" } }, "required": [ - "code", - "message", - "status" - ], - "type": "object" + "barrier_free", + "building_id", + "createdAt", + "id", + "is_outdoor", + "name", + "storey", + "updatedAt", + "width", + "x1", + "x2", + "y1", + "y2" + ] } } } }, - "404": { - "description": "404", + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", "content": { "application/json": { "schema": { @@ -15085,9 +15502,6 @@ } } }, - "x-filc_group_name": "Navigator", - "x-filc_type_name": "@unit Corridor", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -15108,7 +15522,7 @@ "schema": { "type": "object", "properties": { - "corridors": { + "items": { "type": "array", "items": { "type": "object", @@ -15175,10 +15589,14 @@ "y2" ] } + }, + "total": { + "type": "number" } }, "required": [ - "corridors" + "items", + "total" ] } } @@ -15366,9 +15784,7 @@ } } } - }, - "x-filc_group_name": "Navigator", - "x-filc_type_name": "@listof Corridor" + } } }, "/navigator/corridors/{id}": { @@ -15397,74 +15813,12 @@ "schema": { "type": "object", "properties": { - "corridor": { - "type": "object", - "properties": { - "barrier_free": { - "type": "boolean" - }, - "building_id": { - "type": "string" - }, - "createdAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "id": { - "type": "string" - }, - "is_outdoor": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "storey": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "width": { - "type": "number" - }, - "x1": { - "type": "number" - }, - "x2": { - "type": "number" - }, - "y1": { - "type": "number" - }, - "y2": { - "type": "number" - } - }, - "required": [ - "barrier_free", - "building_id", - "createdAt", - "id", - "is_outdoor", - "name", - "storey", - "updatedAt", - "width", - "x1", - "x2", - "y1", - "y2" - ] + "id": { + "type": "string" } }, "required": [ - "corridor" + "id" ] } } @@ -15653,9 +16007,6 @@ } } }, - "x-filc_group_name": "Navigator", - "x-filc_type_name": "@unit Corridor", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -15733,159 +16084,151 @@ "schema": { "type": "object", "properties": { - "corridor": { - "type": "object", - "properties": { - "barrier_free": { - "type": "boolean" - }, - "building_id": { - "type": "string" - }, - "createdAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "id": { - "type": "string" - }, - "is_outdoor": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "storey": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "width": { - "type": "number" - }, - "x1": { - "type": "number" - }, - "x2": { - "type": "number" - }, - "y1": { - "type": "number" - }, - "y2": { - "type": "number" - } - }, - "required": [ - "barrier_free", - "building_id", - "createdAt", - "id", - "is_outdoor", - "name", - "storey", - "updatedAt", - "width", - "x1", - "x2", - "y1", - "y2" - ] - } - }, - "required": [ - "corridor" - ] - } - } - } - }, - "400": { - "description": "400", - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "type": "string" + "barrier_free": { + "type": "boolean" }, - "message": { + "building_id": { "type": "string" }, - "status": { - "type": "integer" - } - }, - "required": [ - "code", - "message", - "status" - ], - "type": "object" - } - } - } - }, - "401": { - "description": "401", - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "type": "string" + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" }, - "message": { + "id": { "type": "string" }, - "status": { - "type": "integer" - } - }, - "required": [ - "code", - "message", - "status" - ], - "type": "object" - } - } - } - }, - "403": { - "description": "403", - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "type": "string" + "is_outdoor": { + "type": "boolean" }, - "message": { + "name": { "type": "string" }, - "status": { - "type": "integer" + "storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "width": { + "type": "number" + }, + "x1": { + "type": "number" + }, + "x2": { + "type": "number" + }, + "y1": { + "type": "number" + }, + "y2": { + "type": "number" } }, "required": [ - "code", - "message", - "status" - ], - "type": "object" + "barrier_free", + "building_id", + "createdAt", + "id", + "is_outdoor", + "name", + "storey", + "updatedAt", + "width", + "x1", + "x2", + "y1", + "y2" + ] } } } }, - "404": { - "description": "404", + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", "content": { "application/json": { "schema": { @@ -15989,9 +16332,6 @@ } } }, - "x-filc_group_name": "Navigator", - "x-filc_type_name": "@unit Corridor", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -16201,9 +16541,6 @@ } } }, - "x-filc_group_name": "Navigator", - "x-filc_type_name": "@unit NavigatorTransfer", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -16757,9 +17094,7 @@ } } } - }, - "x-filc_group_name": "Navigator", - "x-filc_type_name": "@unit FullGraph" + } } }, "/navigator/import": { @@ -17042,9 +17377,6 @@ } } }, - "x-filc_group_name": "Navigator", - "x-filc_type_name": "@unit NavigatorImport", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -17111,60 +17443,52 @@ "schema": { "type": "object", "properties": { - "lift": { - "type": "object", - "properties": { - "building_id": { - "type": "string" - }, - "createdAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "id": { - "type": "string" - }, - "max_storey": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - "min_storey": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - "name": { - "type": "string" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "x": { - "type": "number" - }, - "y": { - "type": "number" - } - }, - "required": [ - "building_id", - "createdAt", - "id", - "max_storey", - "min_storey", - "name", - "updatedAt", - "x", - "y" - ] + "building_id": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string" + }, + "max_storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "min_storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "name": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" } }, "required": [ - "lift" + "building_id", + "createdAt", + "id", + "max_storey", + "min_storey", + "name", + "updatedAt", + "x", + "y" ] } } @@ -17353,9 +17677,6 @@ } } }, - "x-filc_group_name": "Navigator", - "x-filc_type_name": "@unit Lift", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -17376,7 +17697,7 @@ "schema": { "type": "object", "properties": { - "lifts": { + "items": { "type": "array", "items": { "type": "object", @@ -17429,10 +17750,14 @@ "y" ] } + }, + "total": { + "type": "number" } }, "required": [ - "lifts" + "items", + "total" ] } } @@ -17620,9 +17945,7 @@ } } } - }, - "x-filc_group_name": "Navigator", - "x-filc_type_name": "@listof Lift" + } } }, "/navigator/lifts/{id}": { @@ -17651,60 +17974,12 @@ "schema": { "type": "object", "properties": { - "lift": { - "type": "object", - "properties": { - "building_id": { - "type": "string" - }, - "createdAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "id": { - "type": "string" - }, - "max_storey": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - "min_storey": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - "name": { - "type": "string" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "x": { - "type": "number" - }, - "y": { - "type": "number" - } - }, - "required": [ - "building_id", - "createdAt", - "id", - "max_storey", - "min_storey", - "name", - "updatedAt", - "x", - "y" - ] + "id": { + "type": "string" } }, "required": [ - "lift" + "id" ] } } @@ -17893,9 +18168,6 @@ } } }, - "x-filc_group_name": "Navigator", - "x-filc_type_name": "@unit Lift", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -17963,93 +18235,85 @@ "schema": { "type": "object", "properties": { - "lift": { - "type": "object", - "properties": { - "building_id": { - "type": "string" - }, - "createdAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "id": { - "type": "string" - }, - "max_storey": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - "min_storey": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - "name": { - "type": "string" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "x": { - "type": "number" - }, - "y": { - "type": "number" - } - }, - "required": [ - "building_id", - "createdAt", - "id", - "max_storey", - "min_storey", - "name", - "updatedAt", - "x", - "y" - ] - } - }, - "required": [ - "lift" - ] - } - } - } - }, - "400": { - "description": "400", - "content": { - "application/json": { - "schema": { - "properties": { - "code": { + "building_id": { "type": "string" }, - "message": { + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { "type": "string" }, - "status": { - "type": "integer" + "max_storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "min_storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "name": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" } }, "required": [ - "code", - "message", - "status" - ], - "type": "object" + "building_id", + "createdAt", + "id", + "max_storey", + "min_storey", + "name", + "updatedAt", + "x", + "y" + ] } } } }, - "401": { - "description": "401", + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", "content": { "application/json": { "schema": { @@ -18205,9 +18469,6 @@ } } }, - "x-filc_group_name": "Navigator", - "x-filc_type_name": "@unit Lift", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -18278,64 +18539,56 @@ "schema": { "type": "object", "properties": { - "stair": { - "type": "object", - "properties": { - "building_id": { - "type": "string" - }, - "createdAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "id": { - "type": "string" - }, - "max_storey": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - "min_storey": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - "name": { - "type": "string" - }, - "rotation": { - "type": "number" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "x": { - "type": "number" - }, - "y": { - "type": "number" - } - }, - "required": [ - "building_id", - "createdAt", - "id", - "max_storey", - "min_storey", - "name", - "rotation", - "updatedAt", - "x", - "y" - ] + "building_id": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string" + }, + "max_storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "min_storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "name": { + "type": "string" + }, + "rotation": { + "type": "number" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" } }, "required": [ - "stair" + "building_id", + "createdAt", + "id", + "max_storey", + "min_storey", + "name", + "rotation", + "updatedAt", + "x", + "y" ] } } @@ -18524,9 +18777,6 @@ } } }, - "x-filc_group_name": "Navigator", - "x-filc_type_name": "@unit Stair", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -18547,7 +18797,7 @@ "schema": { "type": "object", "properties": { - "stairs": { + "items": { "type": "array", "items": { "type": "object", @@ -18604,10 +18854,14 @@ "y" ] } + }, + "total": { + "type": "number" } }, "required": [ - "stairs" + "items", + "total" ] } } @@ -18795,9 +19049,7 @@ } } } - }, - "x-filc_group_name": "Navigator", - "x-filc_type_name": "@listof Stair" + } } }, "/navigator/stairs/{id}": { @@ -18826,64 +19078,12 @@ "schema": { "type": "object", "properties": { - "stair": { - "type": "object", - "properties": { - "building_id": { - "type": "string" - }, - "createdAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "id": { - "type": "string" - }, - "max_storey": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - "min_storey": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - "name": { - "type": "string" - }, - "rotation": { - "type": "number" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "x": { - "type": "number" - }, - "y": { - "type": "number" - } - }, - "required": [ - "building_id", - "createdAt", - "id", - "max_storey", - "min_storey", - "name", - "rotation", - "updatedAt", - "x", - "y" - ] + "id": { + "type": "string" } }, "required": [ - "stair" + "id" ] } } @@ -19072,9 +19272,6 @@ } } }, - "x-filc_group_name": "Navigator", - "x-filc_type_name": "@unit Stair", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -19145,123 +19342,115 @@ "schema": { "type": "object", "properties": { - "stair": { - "type": "object", - "properties": { - "building_id": { - "type": "string" - }, - "createdAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "id": { - "type": "string" - }, - "max_storey": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - "min_storey": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - "name": { - "type": "string" - }, - "rotation": { - "type": "number" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "x": { - "type": "number" - }, - "y": { - "type": "number" - } - }, - "required": [ - "building_id", - "createdAt", - "id", - "max_storey", - "min_storey", - "name", - "rotation", - "updatedAt", - "x", - "y" - ] - } - }, - "required": [ - "stair" - ] - } - } - } - }, - "400": { - "description": "400", - "content": { - "application/json": { - "schema": { - "properties": { - "code": { + "building_id": { "type": "string" }, - "message": { - "type": "string" + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" }, - "status": { - "type": "integer" - } - }, - "required": [ - "code", - "message", - "status" - ], - "type": "object" - } - } - } - }, - "401": { - "description": "401", - "content": { - "application/json": { - "schema": { - "properties": { - "code": { + "id": { "type": "string" }, - "message": { + "max_storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "min_storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "name": { "type": "string" }, - "status": { - "type": "integer" + "rotation": { + "type": "number" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" } }, "required": [ - "code", - "message", - "status" - ], - "type": "object" + "building_id", + "createdAt", + "id", + "max_storey", + "min_storey", + "name", + "rotation", + "updatedAt", + "x", + "y" + ] } } } }, - "403": { - "description": "403", + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", "content": { "application/json": { "schema": { @@ -19391,9 +19580,6 @@ } } }, - "x-filc_group_name": "Navigator", - "x-filc_type_name": "@unit Stair", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -19416,15 +19602,19 @@ "schema": { "type": "object", "properties": { - "languages": { + "items": { "type": "array", "items": { "type": "string" } + }, + "total": { + "type": "number" } }, "required": [ - "languages" + "items", + "total" ] } } @@ -19612,9 +19802,7 @@ } } } - }, - "x-filc_group_name": "Navigator", - "x-filc_type_name": "@listof Language" + } } }, "/navigator/translations": { @@ -19663,7 +19851,7 @@ "schema": { "type": "object", "properties": { - "translations": { + "items": { "type": "array", "items": { "type": "object", @@ -19684,10 +19872,14 @@ "text_key" ] } + }, + "total": { + "type": "number" } }, "required": [ - "translations" + "items", + "total" ] } } @@ -19876,9 +20068,6 @@ } } }, - "x-filc_group_name": "Navigator", - "x-filc_type_name": "@listof Translation", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -19899,7 +20088,7 @@ "schema": { "type": "object", "properties": { - "translations": { + "items": { "type": "array", "items": { "type": "object", @@ -19920,10 +20109,14 @@ "text_key" ] } + }, + "total": { + "type": "number" } }, "required": [ - "translations" + "items", + "total" ] } } @@ -20112,9 +20305,6 @@ } } }, - "x-filc_group_name": "Navigator", - "x-filc_type_name": "@listof Translation", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -20158,28 +20348,12 @@ "schema": { "type": "object", "properties": { - "translation": { - "type": "object", - "properties": { - "lang_key": { - "type": "string" - }, - "text": { - "type": "string" - }, - "text_key": { - "type": "string" - } - }, - "required": [ - "lang_key", - "text", - "text_key" - ] + "id": { + "type": "string" } }, "required": [ - "translation" + "id" ] } } @@ -20368,9 +20542,6 @@ } } }, - "x-filc_group_name": "Navigator", - "x-filc_type_name": "@unit Translation", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -20430,28 +20601,20 @@ "schema": { "type": "object", "properties": { - "translation": { - "type": "object", - "properties": { - "lang_key": { - "type": "string" - }, - "text": { - "type": "string" - }, - "text_key": { - "type": "string" - } - }, - "required": [ - "lang_key", - "text", - "text_key" - ] + "lang_key": { + "type": "string" + }, + "text": { + "type": "string" + }, + "text_key": { + "type": "string" } }, "required": [ - "translation" + "lang_key", + "text", + "text_key" ] } } @@ -20640,9 +20803,6 @@ } } }, - "x-filc_group_name": "Navigator", - "x-filc_type_name": "@unit Translation", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -20870,9 +21030,7 @@ } } } - }, - "x-filc_group_name": "Navigator", - "x-filc_type_name": "TranslationBundle" + } } }, "/news/announcements": { @@ -20892,7 +21050,8 @@ "cohortIds": { "type": "array", "items": { - "type": "string" + "type": "string", + "format": "uuid" } }, "content": { @@ -20900,12 +21059,91 @@ "items": { "type": "object", "properties": { - "content": {}, + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": {} + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": {} + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + } + ] + }, "type": { "type": "string" } }, "required": [ + "content", "type" ] } @@ -20959,7 +21197,99 @@ "type": "string", "format": "uuid" }, - "content": {}, + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + } + ] + }, "createdAt": { "type": "string", "format": "date-time", @@ -21045,18 +21375,21 @@ "cohortIds": { "type": "array", "items": { - "type": "string" + "type": "string", + "format": "uuid" } }, "kioskIds": { "type": "array", "items": { - "type": "string" + "type": "string", + "format": "uuid" } } }, "required": [ "authorId", + "content", "createdAt", "highlighted", "id", @@ -21259,9 +21592,6 @@ } } }, - "x-filc_group_name": "Announcement", - "x-filc_type_name": "@unit Announcement", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -21338,7 +21668,7 @@ "schema": { "type": "object", "properties": { - "data": { + "items": { "type": "array", "items": { "type": "object", @@ -21347,7 +21677,99 @@ "type": "string", "format": "uuid" }, - "content": {}, + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + } + ] + }, "createdAt": { "type": "string", "format": "date-time", @@ -21433,13 +21855,15 @@ "cohortIds": { "type": "array", "items": { - "type": "string" + "type": "string", + "format": "uuid" } }, "kioskIds": { "type": "array", "items": { - "type": "string" + "type": "string", + "format": "uuid" } }, "author": { @@ -21448,7 +21872,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "format": "uuid" }, "image": { "anyOf": [ @@ -21478,6 +21903,7 @@ }, "required": [ "authorId", + "content", "createdAt", "highlighted", "id", @@ -21500,7 +21926,7 @@ } }, "required": [ - "data", + "items", "total" ] } @@ -21690,9 +22116,6 @@ } } }, - "x-filc_group_name": "Announcement", - "x-filc_type_name": "@listof Announcement @field(.author, Author)", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -21727,7 +22150,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "format": "uuid" } }, "required": [ @@ -21920,9 +22344,6 @@ } } }, - "x-filc_group_name": "Announcement", - "x-filc_type_name": "@nodata", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -21958,7 +22379,99 @@ "type": "string", "format": "uuid" }, - "content": {}, + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + } + ] + }, "createdAt": { "type": "string", "format": "date-time", @@ -22044,13 +22557,15 @@ "cohortIds": { "type": "array", "items": { - "type": "string" + "type": "string", + "format": "uuid" } }, "kioskIds": { "type": "array", "items": { - "type": "string" + "type": "string", + "format": "uuid" } }, "author": { @@ -22059,7 +22574,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "format": "uuid" }, "image": { "anyOf": [ @@ -22089,6 +22605,7 @@ }, "required": [ "authorId", + "content", "createdAt", "highlighted", "id", @@ -22291,9 +22808,6 @@ } } }, - "x-filc_group_name": "Announcement", - "x-filc_type_name": "@unit Announcement @field(.author, Author)", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -22327,7 +22841,8 @@ "cohortIds": { "type": "array", "items": { - "type": "string" + "type": "string", + "format": "uuid" } }, "content": { @@ -22335,12 +22850,91 @@ "items": { "type": "object", "properties": { - "content": {}, + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": {} + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": {} + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + } + ] + }, "type": { "type": "string" } }, "required": [ + "content", "type" ] } @@ -22389,7 +22983,99 @@ "type": "string", "format": "uuid" }, - "content": {}, + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + } + ] + }, "createdAt": { "type": "string", "format": "date-time", @@ -22475,18 +23161,21 @@ "cohortIds": { "type": "array", "items": { - "type": "string" + "type": "string", + "format": "uuid" } }, "kioskIds": { "type": "array", "items": { - "type": "string" + "type": "string", + "format": "uuid" } } }, "required": [ "authorId", + "content", "createdAt", "highlighted", "id", @@ -22689,9 +23378,6 @@ } } }, - "x-filc_group_name": "Announcement", - "x-filc_type_name": "@unit Announcement", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -22729,7 +23415,99 @@ "type": "string", "format": "uuid" }, - "content": {}, + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + } + ] + }, "createdAt": { "type": "string", "format": "date-time", @@ -22815,18 +23593,21 @@ "cohortIds": { "type": "array", "items": { - "type": "string" + "type": "string", + "format": "uuid" } }, "kioskIds": { "type": "array", "items": { - "type": "string" + "type": "string", + "format": "uuid" } } }, "required": [ "authorId", + "content", "createdAt", "highlighted", "id", @@ -23029,9 +23810,6 @@ } } }, - "x-filc_group_name": "Announcement", - "x-filc_type_name": "@unit Announcement", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -23100,7 +23878,99 @@ "type": "string", "format": "uuid" }, - "content": {}, + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + } + ] + }, "createdAt": { "type": "string", "format": "date-time", @@ -23186,18 +24056,21 @@ "cohortIds": { "type": "array", "items": { - "type": "string" + "type": "string", + "format": "uuid" } }, "kioskIds": { "type": "array", "items": { - "type": "string" + "type": "string", + "format": "uuid" } } }, "required": [ "authorId", + "content", "createdAt", "highlighted", "id", @@ -23400,9 +24273,6 @@ } } }, - "x-filc_group_name": "Announcement", - "x-filc_type_name": "@unit Announcement", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -23429,12 +24299,91 @@ "items": { "type": "object", "properties": { - "content": {}, + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": {} + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": {} + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + } + ] + }, "type": { "type": "string" } }, "required": [ + "content", "type" ] } @@ -23476,7 +24425,99 @@ "type": "string", "format": "uuid" }, - "content": {}, + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + } + ] + }, "createdAt": { "type": "string", "format": "date-time", @@ -23515,6 +24556,7 @@ }, "required": [ "authorId", + "content", "createdAt", "id", "publishedAt", @@ -23710,9 +24752,6 @@ } } }, - "x-filc_group_name": "BlogPost", - "x-filc_type_name": "@unit BlogPost", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -23759,7 +24798,7 @@ "schema": { "type": "object", "properties": { - "data": { + "items": { "type": "array", "items": { "type": "object", @@ -23768,7 +24807,99 @@ "type": "string", "format": "uuid" }, - "content": {}, + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + } + ] + }, "createdAt": { "type": "string", "format": "date-time", @@ -23810,7 +24941,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "format": "uuid" }, "image": { "anyOf": [ @@ -23840,6 +24972,7 @@ }, "required": [ "authorId", + "content", "createdAt", "id", "publishedAt", @@ -23855,7 +24988,7 @@ } }, "required": [ - "data", + "items", "total" ] } @@ -24044,9 +25177,7 @@ } } } - }, - "x-filc_group_name": "BlogPost", - "x-filc_type_name": "@listof BlogPost @field(.author, Author)" + } } }, "/news/blogs/{id}": { @@ -24076,7 +25207,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "format": "uuid" } }, "required": [ @@ -24269,9 +25401,6 @@ } } }, - "x-filc_group_name": "BlogPost", - "x-filc_type_name": "@nodata", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -24307,12 +25436,91 @@ "items": { "type": "object", "properties": { - "content": {}, + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": {} + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": {} + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + } + ] + }, "type": { "type": "string" } }, "required": [ + "content", "type" ] } @@ -24342,7 +25550,99 @@ "type": "string", "format": "uuid" }, - "content": {}, + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + } + ] + }, "createdAt": { "type": "string", "format": "date-time", @@ -24381,6 +25681,7 @@ }, "required": [ "authorId", + "content", "createdAt", "id", "publishedAt", @@ -24576,9 +25877,6 @@ } } }, - "x-filc_group_name": "BlogPost", - "x-filc_type_name": "@unit BlogPost", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -24627,7 +25925,7 @@ "schema": { "type": "object", "properties": { - "data": { + "items": { "type": "array", "items": { "type": "object", @@ -24636,7 +25934,99 @@ "type": "string", "format": "uuid" }, - "content": {}, + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + } + ] + }, "createdAt": { "type": "string", "format": "date-time", @@ -24678,7 +26068,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "format": "uuid" }, "image": { "anyOf": [ @@ -24708,6 +26099,7 @@ }, "required": [ "authorId", + "content", "createdAt", "id", "publishedAt", @@ -24723,7 +26115,7 @@ } }, "required": [ - "data", + "items", "total" ] } @@ -24913,9 +26305,6 @@ } } }, - "x-filc_group_name": "BlogPost", - "x-filc_type_name": "@listof BlogPost @field(.author, Author)", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -24952,7 +26341,99 @@ "type": "string", "format": "uuid" }, - "content": {}, + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + } + ] + }, "createdAt": { "type": "string", "format": "date-time", @@ -24994,7 +26475,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "format": "uuid" }, "image": { "anyOf": [ @@ -25024,6 +26506,7 @@ }, "required": [ "authorId", + "content", "createdAt", "id", "publishedAt", @@ -25218,9 +26701,7 @@ } } } - }, - "x-filc_group_name": "BlogPost", - "x-filc_type_name": "@unit BlogPost @field(.author, Author)" + } } }, "/news/blogs/id/{id}": { @@ -25253,7 +26734,99 @@ "type": "string", "format": "uuid" }, - "content": {}, + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + } + ] + }, "createdAt": { "type": "string", "format": "date-time", @@ -25295,7 +26868,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "format": "uuid" }, "image": { "anyOf": [ @@ -25325,6 +26899,7 @@ }, "required": [ "authorId", + "content", "createdAt", "id", "publishedAt", @@ -25520,9 +27095,6 @@ } } }, - "x-filc_group_name": "BlogPost", - "x-filc_type_name": "@unit BlogPost @field(.author, Author)", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -25560,7 +27132,99 @@ "type": "string", "format": "uuid" }, - "content": {}, + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + } + ] + }, "createdAt": { "type": "string", "format": "date-time", @@ -25599,6 +27263,7 @@ }, "required": [ "authorId", + "content", "createdAt", "id", "publishedAt", @@ -25794,9 +27459,6 @@ } } }, - "x-filc_group_name": "BlogPost", - "x-filc_type_name": "@unit BlogPost", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -25834,7 +27496,99 @@ "type": "string", "format": "uuid" }, - "content": {}, + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + } + ] + }, "createdAt": { "type": "string", "format": "date-time", @@ -25873,6 +27627,7 @@ }, "required": [ "authorId", + "content", "createdAt", "id", "publishedAt", @@ -26068,9 +27823,6 @@ } } }, - "x-filc_group_name": "BlogPost", - "x-filc_type_name": "@unit BlogPost", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -26095,7 +27847,8 @@ "cohortIds": { "type": "array", "items": { - "type": "string" + "type": "string", + "format": "uuid" } }, "content": { @@ -26103,12 +27856,91 @@ "items": { "type": "object", "properties": { - "content": {}, + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": {} + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": {} + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + } + ] + }, "type": { "type": "string" } }, "required": [ + "content", "type" ] } @@ -26150,7 +27982,99 @@ "type": "string", "format": "uuid" }, - "content": {}, + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + } + ] + }, "createdAt": { "type": "string", "format": "date-time", @@ -26181,12 +28105,14 @@ "cohortIds": { "type": "array", "items": { - "type": "string" + "type": "string", + "format": "uuid" } } }, "required": [ "authorId", + "content", "createdAt", "id", "title", @@ -26382,9 +28308,6 @@ } } }, - "x-filc_group_name": "SystemMessage", - "x-filc_type_name": "@unit SystemMessage", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -26461,7 +28384,7 @@ "schema": { "type": "object", "properties": { - "data": { + "items": { "type": "array", "items": { "type": "object", @@ -26470,7 +28393,99 @@ "type": "string", "format": "uuid" }, - "content": {}, + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + } + ] + }, "createdAt": { "type": "string", "format": "date-time", @@ -26501,7 +28516,8 @@ "cohortIds": { "type": "array", "items": { - "type": "string" + "type": "string", + "format": "uuid" } }, "author": { @@ -26510,7 +28526,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "format": "uuid" }, "image": { "anyOf": [ @@ -26540,6 +28557,7 @@ }, "required": [ "authorId", + "content", "createdAt", "id", "title", @@ -26555,7 +28573,7 @@ } }, "required": [ - "data", + "items", "total" ] } @@ -26744,9 +28762,7 @@ } } } - }, - "x-filc_group_name": "SystemMessage", - "x-filc_type_name": "@listof SystemMessage @field(.author, Author)" + } } }, "/news/system-messages/{id}": { @@ -26776,7 +28792,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "format": "uuid" } }, "required": [ @@ -26969,9 +28986,6 @@ } } }, - "x-filc_group_name": "SystemMessage", - "x-filc_type_name": "@nodata", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -27007,7 +29021,99 @@ "type": "string", "format": "uuid" }, - "content": {}, + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + } + ] + }, "createdAt": { "type": "string", "format": "date-time", @@ -27038,7 +29144,8 @@ "cohortIds": { "type": "array", "items": { - "type": "string" + "type": "string", + "format": "uuid" } }, "author": { @@ -27047,7 +29154,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "format": "uuid" }, "image": { "anyOf": [ @@ -27077,6 +29185,7 @@ }, "required": [ "authorId", + "content", "createdAt", "id", "title", @@ -27272,9 +29381,6 @@ } } }, - "x-filc_group_name": "SystemMessage", - "x-filc_type_name": "@unit SystemMessage @field(.author, Author)", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -27308,7 +29414,8 @@ "cohortIds": { "type": "array", "items": { - "type": "string" + "type": "string", + "format": "uuid" } }, "content": { @@ -27316,12 +29423,91 @@ "items": { "type": "object", "properties": { - "content": {}, + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": {} + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": {} + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + } + ] + }, "type": { "type": "string" } }, "required": [ + "content", "type" ] } @@ -27357,7 +29543,99 @@ "type": "string", "format": "uuid" }, - "content": {}, + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + } + } + ] + }, "createdAt": { "type": "string", "format": "date-time", @@ -27388,12 +29666,14 @@ "cohortIds": { "type": "array", "items": { - "type": "string" + "type": "string", + "format": "uuid" } } }, "required": [ "authorId", + "content", "createdAt", "id", "title", @@ -27589,9 +29869,6 @@ } } }, - "x-filc_group_name": "SystemMessage", - "x-filc_type_name": "@unit SystemMessage", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -27807,9 +30084,7 @@ } } } - }, - "x-filc_group_name": "Ping", - "x-filc_type_name": "@unit PingResponse" + } } }, "/ping/uptime": { @@ -28024,9 +30299,7 @@ } } } - }, - "x-filc_group_name": "Ping", - "x-filc_type_name": "@unit UptimeResponse" + } } }, "/roles": { @@ -28288,7 +30561,7 @@ "schema": { "type": "object", "properties": { - "roles": { + "items": { "type": "array", "items": { "type": "object", @@ -28308,10 +30581,14 @@ "name" ] } + }, + "total": { + "type": "number" } }, "required": [ - "roles" + "items", + "total" ] } } @@ -28985,15 +31262,19 @@ "schema": { "type": "object", "properties": { - "permissions": { + "items": { "type": "array", "items": { "type": "string" } + }, + "total": { + "type": "number" } }, "required": [ - "permissions" + "items", + "total" ] } } @@ -29205,13 +31486,15 @@ "deletedCohortIds": { "type": "array", "items": { - "type": "string" + "type": "string", + "format": "uuid" } }, "deletedTeacherIds": { "type": "array", "items": { - "type": "string" + "type": "string", + "format": "uuid" } } }, @@ -29407,9 +31690,6 @@ } } }, - "x-filc_group_name": "Timetable", - "x-filc_type_name": "@unit Timetable", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -29444,7 +31724,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "format": "uuid" } }, "required": [ @@ -29637,9 +31918,6 @@ } } }, - "x-filc_group_name": "Timetable", - "x-filc_type_name": "@unit Timetable", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -29705,264 +31983,8 @@ "x-native-type": "date" }, "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "updatedAt": { "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "validFrom": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "validTo": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "createdAt", - "id", - "name", - "updatedAt", - "validFrom", - "validTo" - ] - } - } - } - }, - "400": { - "description": "400", - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "type": "string" - }, - "message": { - "type": "string" - }, - "status": { - "type": "integer" - } - }, - "required": [ - "code", - "message", - "status" - ], - "type": "object" - } - } - } - }, - "401": { - "description": "401", - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "type": "string" - }, - "message": { - "type": "string" - }, - "status": { - "type": "integer" - } - }, - "required": [ - "code", - "message", - "status" - ], - "type": "object" - } - } - } - }, - "403": { - "description": "403", - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "type": "string" - }, - "message": { - "type": "string" - }, - "status": { - "type": "integer" - } - }, - "required": [ - "code", - "message", - "status" - ], - "type": "object" - } - } - } - }, - "404": { - "description": "404", - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "type": "string" - }, - "message": { - "type": "string" - }, - "status": { - "type": "integer" - } - }, - "required": [ - "code", - "message", - "status" - ], - "type": "object" - } - } - } - }, - "409": { - "description": "409", - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "type": "string" - }, - "message": { - "type": "string" - }, - "status": { - "type": "integer" - } - }, - "required": [ - "code", - "message", - "status" - ], - "type": "object" - } - } - } - }, - "429": { - "description": "429", - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "type": "string" - }, - "message": { - "type": "string" - }, - "status": { - "type": "integer" - } - }, - "required": [ - "code", - "message", - "status" - ], - "type": "object" - } - } - } - }, - "500": { - "description": "500", - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "type": "string" - }, - "message": { - "type": "string" - }, - "status": { - "type": "integer" - } - }, - "required": [ - "code", - "message", - "status" - ], - "type": "object" - } - } - } - } - }, - "x-filc_group_name": "Timetable", - "x-filc_type_name": "@unit Timetable", - "x-filc_auth": "true", - "security": [ - { - "sessionAuth": [] - } - ] - } - }, - "/timetable/timetables/latestValid": { - "get": { - "operationId": "getTimetableTimetablesLatestValid", - "description": "Get the latest valid timetable.", - "tags": [ - "Timetable" - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "createdAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "id": { - "type": "string" + "format": "uuid" }, "name": { "type": "string" @@ -30188,9 +32210,6 @@ } } }, - "x-filc_group_name": "Timetable", - "x-filc_type_name": "@unit Timetable", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -30198,10 +32217,10 @@ ] } }, - "/timetable/timetables": { + "/timetable/timetables/latestValid": { "get": { - "operationId": "getTimetableTimetables", - "description": "Get all timetables from the database.", + "operationId": "getTimetableTimetablesLatestValid", + "description": "Get the latest valid timetable.", "tags": [ "Timetable" ], @@ -30211,56 +32230,54 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "createdAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "validFrom": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "validTo": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } + "type": "object", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" }, - "required": [ - "createdAt", - "id", - "name", - "updatedAt", - "validFrom", - "validTo" - ] - } + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "validFrom": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "validTo": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "createdAt", + "id", + "name", + "updatedAt", + "validFrom", + "validTo" + ] } } } @@ -30448,9 +32465,6 @@ } } }, - "x-filc_group_name": "Timetable", - "x-filc_type_name": "@listof Timetable", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -30458,24 +32472,13 @@ ] } }, - "/timetable/timetables/{id}/preview-delete": { + "/timetable/timetables": { "get": { - "operationId": "getTimetableTimetablesByIdPreviewDelete", - "description": "Preview the impact of deleting a timetable. Orphaned cohorts will be deleted along with the timetable.", + "operationId": "getTimetableTimetables", + "description": "Get all timetables from the database.", "tags": [ "Timetable" ], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], "responses": { "200": { "description": "OK", @@ -30484,90 +32487,66 @@ "schema": { "type": "object", "properties": { - "cohorts": { + "items": { "type": "array", "items": { "type": "object", "properties": { - "becomesOrphaned": { - "type": "boolean" + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" }, "id": { - "type": "string" + "type": "string", + "format": "uuid" }, "name": { "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "validFrom": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "validTo": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] } }, "required": [ - "becomesOrphaned", + "createdAt", "id", - "name" + "name", + "updatedAt", + "validFrom", + "validTo" ] } }, - "isCurrentTimetable": { - "type": "boolean" - }, - "targetTimetable": { - "anyOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - }, - "required": [ - "id", - "name" - ] - }, - { - "type": "null" - } - ] - }, - "totals": { - "type": "object", - "properties": { - "danglingUsersCleaned": { - "type": "number" - }, - "lessonsDeleted": { - "type": "number" - }, - "movedLessonsDeleted": { - "type": "number" - }, - "orphanedCohorts": { - "type": "number" - }, - "substitutionsDeleted": { - "type": "number" - }, - "survivingCohorts": { - "type": "number" - } - }, - "required": [ - "danglingUsersCleaned", - "lessonsDeleted", - "movedLessonsDeleted", - "orphanedCohorts", - "substitutionsDeleted", - "survivingCohorts" - ] + "total": { + "type": "number" } }, "required": [ - "cohorts", - "isCurrentTimetable", - "targetTimetable", - "totals" + "items", + "total" ] } } @@ -30756,9 +32735,6 @@ } } }, - "x-filc_group_name": "Timetable", - "x-filc_type_name": "@unit Timetable", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -30766,69 +32742,119 @@ ] } }, - "/timetable/timetables/valid": { + "/timetable/timetables/{id}/preview-delete": { "get": { - "operationId": "getTimetableTimetablesValid", - "description": "Get all the latest valid timetables.", + "operationId": "getTimetableTimetablesByIdPreviewDelete", + "description": "Preview the impact of deleting a timetable. Orphaned cohorts will be deleted along with the timetable.", "tags": [ "Timetable" ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "createdAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "validFrom": { - "anyOf": [ - { - "type": "string" + "type": "object", + "properties": { + "cohorts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "becomesOrphaned": { + "type": "boolean" }, - { - "type": "null" - } - ] - }, - "validTo": { - "anyOf": [ - { - "type": "string" + "id": { + "type": "string", + "format": "uuid" }, - { - "type": "null" + "name": { + "type": "string" } + }, + "required": [ + "becomesOrphaned", + "id", + "name" ] } }, - "required": [ - "createdAt", - "id", - "name", - "updatedAt", - "validFrom", - "validTo" - ] - } + "isCurrentTimetable": { + "type": "boolean" + }, + "targetTimetable": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + }, + { + "type": "null" + } + ] + }, + "totals": { + "type": "object", + "properties": { + "danglingUsersCleaned": { + "type": "number" + }, + "lessonsDeleted": { + "type": "number" + }, + "movedLessonsDeleted": { + "type": "number" + }, + "orphanedCohorts": { + "type": "number" + }, + "substitutionsDeleted": { + "type": "number" + }, + "survivingCohorts": { + "type": "number" + } + }, + "required": [ + "danglingUsersCleaned", + "lessonsDeleted", + "movedLessonsDeleted", + "orphanedCohorts", + "substitutionsDeleted", + "survivingCohorts" + ] + } + }, + "required": [ + "cohorts", + "isCurrentTimetable", + "targetTimetable", + "totals" + ] } } } @@ -31016,9 +33042,6 @@ } } }, - "x-filc_group_name": "Timetable", - "x-filc_type_name": "@listof Timetable", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -31026,73 +33049,13 @@ ] } }, - "/timetable/import": { - "post": { - "operationId": "postTimetableImport", - "description": "Import a timetable from an Oman or aSc 2012 XML file.", + "/timetable/timetables/valid": { + "get": { + "operationId": "getTimetableTimetablesValid", + "description": "Get all the latest valid timetables.", "tags": [ - "Timetable", - "Import" + "Timetable" ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "file": { - "type": "string", - "contentMediaType": "*/*" - }, - "name": { - "type": "string" - }, - "validFrom": { - "type": "string", - "pattern": "^\\d{4}-\\d{2}-\\d{2}$" - }, - "validTo": { - "type": "string", - "pattern": "^\\d{4}-\\d{2}-\\d{2}$" - } - }, - "required": [ - "file", - "name", - "validFrom" - ] - } - }, - "multipart/form-data": { - "schema": { - "type": "object", - "properties": { - "file": { - "type": "string", - "contentMediaType": "*/*" - }, - "name": { - "type": "string" - }, - "validFrom": { - "type": "string", - "pattern": "^\\d{4}-\\d{2}-\\d{2}$" - }, - "validTo": { - "type": "string", - "pattern": "^\\d{4}-\\d{2}-\\d{2}$" - } - }, - "required": [ - "file", - "name", - "validFrom" - ] - } - } - } - }, "responses": { "200": { "description": "OK", @@ -31101,12 +33064,66 @@ "schema": { "type": "object", "properties": { - "ok": { - "const": true + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "validFrom": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "validTo": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "createdAt", + "id", + "name", + "updatedAt", + "validFrom", + "validTo" + ] + } + }, + "total": { + "type": "number" } }, "required": [ - "ok" + "items", + "total" ] } } @@ -31294,115 +33311,96 @@ } } } - } + }, + "security": [ + { + "sessionAuth": [] + } + ] } }, - "/timetable/classrooms/getAll": { - "get": { - "operationId": "getTimetableClassroomsGetAll", - "description": "Get all classrooms from the database.", + "/timetable/import": { + "post": { + "operationId": "postTimetableImport", + "description": "Import a timetable from an Oman or aSc 2012 XML file.", "tags": [ - "Classroom" + "Timetable", + "Import" ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "file": { + "type": "string", + "contentMediaType": "*/*" + }, + "name": { + "type": "string" + }, + "validFrom": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + }, + "validTo": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + } + }, + "required": [ + "file", + "name", + "validFrom" + ] + } + }, + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "file": { + "type": "string", + "contentMediaType": "*/*" + }, + "name": { + "type": "string" + }, + "validFrom": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + }, + "validTo": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + } + }, + "required": [ + "file", + "name", + "validFrom" + ] + } + } + } + }, "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "building_id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "capacity": { - "anyOf": [ - { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - { - "type": "null" - } - ] - }, - "description": { - "type": "string" - }, - "id": { - "type": "string" - }, - "mapped": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "rotation": { - "type": "number" - }, - "short": { - "type": "string" - }, - "size_x": { - "type": "number" - }, - "size_y": { - "type": "number" - }, - "size_z": { - "type": "number" - }, - "storey": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - "type_id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "x": { - "type": "number" - }, - "y": { - "type": "number" - } - }, - "required": [ - "building_id", - "capacity", - "description", - "id", - "mapped", - "name", - "rotation", - "short", - "size_x", - "size_y", - "size_z", - "storey", - "type_id", - "x", - "y" - ] - } + "type": "object", + "properties": { + "ok": { + "const": true + } + }, + "required": [ + "ok" + ] } } } @@ -31589,164 +33587,127 @@ } } } - }, - "x-filc_group_name": "Classroom", - "x-filc_type_name": "@listof Classroom" + } } }, - "/timetable/classrooms/getAvailable": { + "/timetable/classrooms/getAll": { "get": { - "operationId": "getTimetableClassroomsGetAvailable", - "description": "Get classrooms that are free for a given date, day and period.", + "operationId": "getTimetableClassroomsGetAll", + "description": "Get all classrooms from the database.", "tags": [ "Classroom" ], - "parameters": [ - { - "name": "date", - "in": "query", - "required": true, - "schema": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "allowEmptyValue": true, - "allowReserved": true - }, - { - "name": "startingDay", - "in": "query", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - }, - "allowEmptyValue": true, - "allowReserved": true - }, - { - "name": "startingPeriod", - "in": "query", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - }, - "allowEmptyValue": true, - "allowReserved": true - }, - { - "name": "timetableId", - "in": "query", - "required": false, - "schema": { - "type": "string", - "format": "uuid" - }, - "allowEmptyValue": true, - "allowReserved": true - } - ], "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "building_id": { - "anyOf": [ - { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "building_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "capacity": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "description": { "type": "string" }, - { - "type": "null" - } - ] - }, - "capacity": { - "anyOf": [ - { + "id": { + "type": "string" + }, + "mapped": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "rotation": { + "type": "number" + }, + "short": { + "type": "string" + }, + "size_x": { + "type": "number" + }, + "size_y": { + "type": "number" + }, + "size_z": { + "type": "number" + }, + "storey": { "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, - { - "type": "null" - } - ] - }, - "description": { - "type": "string" - }, - "id": { - "type": "string" - }, - "mapped": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "rotation": { - "type": "number" - }, - "short": { - "type": "string" - }, - "size_x": { - "type": "number" - }, - "size_y": { - "type": "number" - }, - "size_z": { - "type": "number" - }, - "storey": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - "type_id": { - "anyOf": [ - { - "type": "string" + "type_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] }, - { - "type": "null" + "x": { + "type": "number" + }, + "y": { + "type": "number" } + }, + "required": [ + "building_id", + "capacity", + "description", + "id", + "mapped", + "name", + "rotation", + "short", + "size_x", + "size_y", + "size_z", + "storey", + "type_id", + "x", + "y" ] - }, - "x": { - "type": "number" - }, - "y": { - "type": "number" } }, - "required": [ - "building_id", - "capacity", - "description", - "id", - "mapped", - "name", - "rotation", - "short", - "size_x", - "size_y", - "size_z", - "storey", - "type_id", - "x", - "y" - ] - } + "total": { + "type": "number" + } + }, + "required": [ + "items", + "total" + ] } } } @@ -31933,91 +33894,174 @@ } } } - }, - "x-filc_group_name": "Classroom", - "x-filc_type_name": "@listof Classroom" + } } }, - "/timetable/cohorts/getAllForTimetable/{timetableId}": { + "/timetable/classrooms/getAvailable": { "get": { - "operationId": "getTimetableCohortsGetAllForTimetableByTimetableId", - "description": "Get cohorts for a given timetable from the database.", + "operationId": "getTimetableClassroomsGetAvailable", + "description": "Get classrooms that are free for a given date, day and period.", "tags": [ - "Cohort" + "Classroom" ], "parameters": [ { - "name": "timetableId", - "in": "path", + "name": "date", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "allowEmptyValue": true, + "allowReserved": true + }, + { + "name": "startingDay", + "in": "query", "required": true, "schema": { "type": "string", "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "classroomIds": { - "anyOf": [ - { - "type": "array", - "items": { - "type": "string" - } - }, - { - "type": "null" - } - ] - }, - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "short": { - "type": "string" - }, - "teacherId": { - "anyOf": [ - { + }, + "allowEmptyValue": true, + "allowReserved": true + }, + { + "name": "startingPeriod", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "allowEmptyValue": true, + "allowReserved": true + }, + { + "name": "timetableId", + "in": "query", + "required": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "allowEmptyValue": true, + "allowReserved": true + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "building_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "capacity": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "description": { "type": "string" }, - { - "type": "null" - } - ] - }, - "timetableId": { - "anyOf": [ - { + "id": { "type": "string" }, - { - "type": "null" + "mapped": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "rotation": { + "type": "number" + }, + "short": { + "type": "string" + }, + "size_x": { + "type": "number" + }, + "size_y": { + "type": "number" + }, + "size_z": { + "type": "number" + }, + "storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "type_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" } + }, + "required": [ + "building_id", + "capacity", + "description", + "id", + "mapped", + "name", + "rotation", + "short", + "size_x", + "size_y", + "size_z", + "storey", + "type_id", + "x", + "y" ] } }, - "required": [ - "classroomIds", - "id", - "name", - "short", - "teacherId", - "timetableId" - ] - } + "total": { + "type": "number" + } + }, + "required": [ + "items", + "total" + ] } } } @@ -32204,27 +34248,19 @@ } } } - }, - "x-filc_group_name": "Cohort", - "x-filc_type_name": "@listof Cohort", - "x-filc_auth": "true", - "security": [ - { - "sessionAuth": [] - } - ] + } } }, - "/timetable/groups/getForCohort/{cohortId}": { + "/timetable/cohorts/getAllForTimetable/{timetableId}": { "get": { - "operationId": "getTimetableGroupsGetForCohortByCohortId", - "description": "Get the groups of a cohort, marking the current user selection.", + "operationId": "getTimetableCohortsGetAllForTimetableByTimetableId", + "description": "Get cohorts for a given timetable from the database.", "tags": [ - "Group" + "Cohort" ], "parameters": [ { - "name": "cohortId", + "name": "timetableId", "in": "path", "required": true, "schema": { @@ -32239,75 +34275,77 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "divisionLabel": { - "anyOf": [ - { - "type": "string" + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "classroomIds": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "divisionTag": { - "anyOf": [ - { - "type": "string" + "id": { + "type": "string", + "format": "uuid" }, - { - "type": "null" - } - ] - }, - "entireClass": { - "type": "boolean" - }, - "id": { - "type": "string", - "format": "uuid" - }, - "name": { - "type": "string" - }, - "selected": { - "type": "boolean" - }, - "studentCount": { - "anyOf": [ - { - "type": "number" + "name": { + "type": "string" }, - { - "type": "null" - } - ] - }, - "teacherId": { - "anyOf": [ - { + "short": { "type": "string" }, - { - "type": "null" + "teacherId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + }, + "timetableId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] } + }, + "required": [ + "classroomIds", + "id", + "name", + "short", + "teacherId", + "timetableId" ] } }, - "required": [ - "divisionLabel", - "divisionTag", - "entireClass", - "id", - "name", - "selected", - "studentCount", - "teacherId" - ] - } + "total": { + "type": "number" + } + }, + "required": [ + "items", + "total" + ] } } } @@ -32495,9 +34533,6 @@ } } }, - "x-filc_group_name": "Group", - "x-filc_type_name": "@listof Group", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -32505,32 +34540,24 @@ ] } }, - "/timetable/groups/select": { - "post": { - "operationId": "postTimetableGroupsSelect", - "description": "Select the group the current user belongs to for a division. Replaces any previous membership in that division.", + "/timetable/groups/getForCohort/{cohortId}": { + "get": { + "operationId": "getTimetableGroupsGetForCohortByCohortId", + "description": "Get the groups of a cohort, marking the current user selection.", "tags": [ "Group" ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "groupId": { - "type": "string", - "format": "uuid" - } - }, - "required": [ - "groupId" - ] - } + "parameters": [ + { + "name": "cohortId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" } } - }, + ], "responses": { "200": { "description": "OK", @@ -32539,31 +34566,85 @@ "schema": { "type": "object", "properties": { - "divisionTag": { - "anyOf": [ - { - "type": "string" + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "divisionLabel": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "divisionTag": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "entireClass": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "selected": { + "type": "boolean" + }, + "studentCount": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "teacherId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + } }, - { - "type": "null" - } - ] + "required": [ + "divisionLabel", + "divisionTag", + "entireClass", + "id", + "name", + "selected", + "studentCount", + "teacherId" + ] + } }, - "selectedGroupId": { - "anyOf": [ - { - "type": "string", - "format": "uuid" - }, - { - "type": "null" - } - ] + "total": { + "type": "number" } }, "required": [ - "divisionTag", - "selectedGroupId" + "items", + "total" ] } } @@ -32752,9 +34833,6 @@ } } }, - "x-filc_group_name": "Group", - "x-filc_type_name": "@object Group @field(.selectedGroupId, string) @field(.divisionTag, string)", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -32762,55 +34840,66 @@ ] } }, - "/timetable/periods/getAll": { - "get": { - "operationId": "getTimetablePeriodsGetAll", - "description": "Get all period definitions used in a given timetable, sorted by period number.", + "/timetable/groups/select": { + "post": { + "operationId": "postTimetableGroupsSelect", + "description": "Select the group the current user belongs to for a division. Replaces any previous membership in that division.", "tags": [ - "Period" + "Group" ], - "parameters": [ - { - "name": "timetableId", - "in": "query", - "schema": { - "type": "string", - "format": "uuid" - }, - "allowEmptyValue": true, - "allowReserved": true + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "groupId": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "groupId" + ] + } + } } - ], + }, "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "endTime": { - "type": "string" - }, - "id": { - "type": "string" - }, - "period": { - "type": "number" - }, - "startTime": { - "type": "string" - } + "type": "object", + "properties": { + "divisionTag": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] }, - "required": [ - "endTime", - "id", - "period", - "startTime" - ] - } + "selectedGroupId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "divisionTag", + "selectedGroupId" + ] } } } @@ -32998,16 +35087,31 @@ } } }, - "x-filc_group_name": "Period", - "x-filc_type_name": "@listof Period" + "security": [ + { + "sessionAuth": [] + } + ] } }, - "/timetable/subjects": { + "/timetable/periods/getAll": { "get": { - "operationId": "getTimetableSubjects", - "description": "Get all subjects from the database.", + "operationId": "getTimetablePeriodsGetAll", + "description": "Get all period definitions used in a given timetable, sorted by period number.", "tags": [ - "Subject" + "Period" + ], + "parameters": [ + { + "name": "timetableId", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + }, + "allowEmptyValue": true, + "allowReserved": true + } ], "responses": { "200": { @@ -33015,38 +35119,43 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "createdAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "short": { - "type": "string" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "endTime": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "period": { + "type": "number" + }, + "startTime": { + "type": "string" + } + }, + "required": [ + "endTime", + "id", + "period", + "startTime" + ] } }, - "required": [ - "createdAt", - "id", - "name", - "short", - "updatedAt" - ] - } + "total": { + "type": "number" + } + }, + "required": [ + "items", + "total" + ] } } } @@ -33233,23 +35342,15 @@ } } } - }, - "x-filc_group_name": "Subject", - "x-filc_type_name": "@listof Subject", - "x-filc_auth": "true", - "security": [ - { - "sessionAuth": [] - } - ] + } } }, - "/timetable/teachers/getAll": { + "/timetable/subjects": { "get": { - "operationId": "getTimetableTeachersGetAll", - "description": "Get all teachers from the database.", + "operationId": "getTimetableSubjects", + "description": "Get all subjects from the database.", "tags": [ - "Teacher" + "Subject" ], "responses": { "200": { @@ -33257,30 +35358,51 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "firstName": { - "type": "string" - }, - "id": { - "type": "string" - }, - "lastName": { - "type": "string" - }, - "short": { - "type": "string" + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "createdAt", + "id", + "name", + "short", + "updatedAt" + ] } }, - "required": [ - "firstName", - "id", - "lastName", - "short" - ] - } + "total": { + "type": "number" + } + }, + "required": [ + "items", + "total" + ] } } } @@ -33468,14 +35590,17 @@ } } }, - "x-filc_group_name": "Teacher", - "x-filc_type_name": "@listof Teacher" + "security": [ + { + "sessionAuth": [] + } + ] } }, - "/timetable/teachers": { + "/timetable/teachers/getAll": { "get": { - "operationId": "getTimetableTeachers", - "description": "List all teachers with their email and linked user.", + "operationId": "getTimetableTeachersGetAll", + "description": "Get all teachers from the database.", "tags": [ "Teacher" ], @@ -33485,84 +35610,43 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "email": { - "anyOf": [ - { - "type": "string", - "format": "email" - }, - { - "type": "null" - } - ] - }, - "firstName": { - "type": "string" - }, - "id": { - "type": "string", - "format": "uuid" - }, - "lastName": { - "type": "string" - }, - "short": { - "type": "string" - }, - "user": { - "anyOf": [ - { - "type": "object", - "properties": { - "email": { - "type": "string", - "format": "email" - }, - "id": { - "type": "string", - "format": "uuid" - }, - "name": { - "type": "string" - } - }, - "required": [ - "email", - "id", - "name" - ] + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "firstName": { + "type": "string" }, - { - "type": "null" - } - ] - }, - "userId": { - "anyOf": [ - { + "id": { "type": "string", "format": "uuid" }, - { - "type": "null" + "lastName": { + "type": "string" + }, + "short": { + "type": "string" } + }, + "required": [ + "firstName", + "id", + "lastName", + "short" ] } }, - "required": [ - "email", - "firstName", - "id", - "lastName", - "short", - "user", - "userId" - ] - } + "total": { + "type": "number" + } + }, + "required": [ + "items", + "total" + ] } } } @@ -33749,21 +35833,13 @@ } } } - }, - "x-filc_group_name": "Teacher", - "x-filc_type_name": "@listof @unit TeacherListItem", - "x-filc_auth": "true", - "security": [ - { - "sessionAuth": [] - } - ] + } } }, - "/timetable/teachers/me": { + "/timetable/teachers": { "get": { - "operationId": "getTimetableTeachersMe", - "description": "Get the signed-in user's linked teacher, if any.", + "operationId": "getTimetableTeachers", + "description": "List all teachers with their email and linked user.", "tags": [ "Teacher" ], @@ -33773,33 +35849,95 @@ "content": { "application/json": { "schema": { - "anyOf": [ - { - "type": "object", - "properties": { - "firstName": { - "type": "string" - }, - "id": { - "type": "string" - }, - "lastName": { - "type": "string" + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "email": { + "anyOf": [ + { + "type": "string", + "format": "email" + }, + { + "type": "null" + } + ] + }, + "firstName": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "lastName": { + "type": "string" + }, + "short": { + "type": "string" + }, + "user": { + "anyOf": [ + { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + } + }, + "required": [ + "email", + "id", + "name" + ] + }, + { + "type": "null" + } + ] + }, + "userId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + } }, - "short": { - "type": "string" - } - }, - "required": [ - "firstName", - "id", - "lastName", - "short" - ] + "required": [ + "email", + "firstName", + "id", + "lastName", + "short", + "user", + "userId" + ] + } }, - { - "type": "null" + "total": { + "type": "number" } + }, + "required": [ + "items", + "total" ] } } @@ -33988,9 +36126,6 @@ } } }, - "x-filc_group_name": "Teacher", - "x-filc_type_name": "@unit Teacher", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -33998,138 +36133,47 @@ ] } }, - "/timetable/teachers/{id}": { - "patch": { - "operationId": "patchTimetableTeachersById", - "description": "Update a teacher email and/or linked user.", + "/timetable/teachers/me": { + "get": { + "operationId": "getTimetableTeachersMe", + "description": "Get the signed-in user's linked teacher, if any.", "tags": [ "Teacher" ], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "requestBody": { - "required": false, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "email": { - "anyOf": [ - { - "type": "string", - "format": "email" - }, - { - "type": "null" - } - ] - }, - "userId": { - "anyOf": [ - { - "type": "string", - "format": "uuid" - }, - { - "type": "null" - } - ] - } - } - } - } - } - }, "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "email": { - "anyOf": [ - { - "type": "string", - "format": "email" - }, - { - "type": "null" - } - ] - }, - "firstName": { - "type": "string" - }, - "id": { - "type": "string", - "format": "uuid" - }, - "lastName": { - "type": "string" - }, - "short": { - "type": "string" - }, - "user": { - "anyOf": [ - { - "type": "object", - "properties": { - "email": { - "type": "string", - "format": "email" - }, - "id": { - "type": "string", - "format": "uuid" - }, - "name": { - "type": "string" - } - }, - "required": [ - "email", - "id", - "name" - ] + "anyOf": [ + { + "type": "object", + "properties": { + "firstName": { + "type": "string" }, - { - "type": "null" - } - ] - }, - "userId": { - "anyOf": [ - { + "id": { "type": "string", "format": "uuid" }, - { - "type": "null" + "lastName": { + "type": "string" + }, + "short": { + "type": "string" } + }, + "required": [ + "firstName", + "id", + "lastName", + "short" ] + }, + { + "type": "null" } - }, - "required": [ - "email", - "firstName", - "id", - "lastName", - "short", - "user", - "userId" ] } } @@ -34318,9 +36362,6 @@ } } }, - "x-filc_group_name": "Teacher", - "x-filc_type_name": "@unit TeacherListItem", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -34328,318 +36369,139 @@ ] } }, - "/timetable/lessons/getForCohort/{cohortId}": { - "get": { - "operationId": "getTimetableLessonsGetForCohortByCohortId", - "description": "Get lessons for a given cohort from the database.", + "/timetable/teachers/{id}": { + "patch": { + "operationId": "patchTimetableTeachersById", + "description": "Update a teacher email and/or linked user.", "tags": [ - "Lesson" + "Teacher" ], "parameters": [ { - "name": "cohortId", + "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } - }, - { - "name": "timetableId", - "in": "query", - "schema": { - "type": "string", - "format": "uuid" - }, - "allowEmptyValue": true, - "allowReserved": true } ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "email": { + "anyOf": [ + { + "type": "string", + "format": "email" + }, + { + "type": "null" + } + ] + }, + "userId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + } + } + } + } + } + }, "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "classrooms": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "short": { - "type": "string" - } - }, - "required": [ - "id", - "name", - "short" - ] - } - }, - "cohorts": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "short": { - "type": "string" - } - }, - "required": [ - "id", - "name", - "short" - ] - } - }, - "day": { - "type": "object", - "properties": { - "createdAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "days": { - "anyOf": [ - { - "type": "array", - "items": { - "type": "string" - } - }, - { - "type": "null" - } - ] - }, - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "short": { - "type": "string" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - } + "type": "object", + "properties": { + "email": { + "anyOf": [ + { + "type": "string", + "format": "email" }, - "required": [ - "createdAt", - "days", - "id", - "name", - "short", - "updatedAt" - ] - }, - "groups": { - "type": "array", - "items": { + { + "type": "null" + } + ] + }, + "firstName": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "lastName": { + "type": "string" + }, + "short": { + "type": "string" + }, + "user": { + "anyOf": [ + { "type": "object", "properties": { - "divisionTag": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "entireClass": { - "type": "boolean" + "email": { + "type": "string", + "format": "email" }, "id": { - "type": "string" + "type": "string", + "format": "uuid" }, "name": { "type": "string" } }, "required": [ - "divisionTag", - "entireClass", + "email", "id", "name" ] + }, + { + "type": "null" } - }, - "groupsIds": { - "type": "array", - "items": { - "type": "string" - } - }, - "id": { - "type": "string" - }, - "period": { - "anyOf": [ - { - "type": "object", - "properties": { - "endTime": { - "type": "string" - }, - "id": { - "type": "string" - }, - "period": { - "type": "number" - }, - "startTime": { - "type": "string" - } - }, - "required": [ - "endTime", - "id", - "period", - "startTime" - ] - }, - { - "type": "null" - } - ] - }, - "periodsPerWeek": { - "type": "number" - }, - "subject": { - "anyOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "short": { - "type": "string" - } - }, - "required": [ - "id", - "name", - "short" - ] - }, - { - "type": "null" - } - ] - }, - "teachers": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "short": { - "type": "string" - } - }, - "required": [ - "id", - "name", - "short" - ] - } - }, - "termDefinitionId": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "weekDefinition": { - "anyOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "short": { - "type": "string" - }, - "weeks": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": [ - "id", - "name", - "short", - "weeks" - ] - }, - { - "type": "null" - } - ] - }, - "weeksDefinitionId": { - "type": "string" - } + ] }, - "required": [ - "classrooms", - "cohorts", - "groups", - "groupsIds", - "id", - "period", - "periodsPerWeek", - "subject", - "teachers", - "termDefinitionId", - "weekDefinition", - "weeksDefinitionId" - ] - } + "userId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "email", + "firstName", + "id", + "lastName", + "short", + "user", + "userId" + ] } } } @@ -34827,26 +36689,39 @@ } } }, - "x-filc_group_name": "Lesson", - "x-filc_type_name": "@listof EnrichedLesson @field(.classrooms, List) @field(.cohorts, List) @field(.day, DayDefinition) @field(.period, Period) @field(.subject, Subject) @field(.teachers, List)" + "security": [ + { + "sessionAuth": [] + } + ] } }, - "/timetable/lessons/getForId/{lessonId}": { + "/timetable/lessons/getForCohort/{cohortId}": { "get": { - "operationId": "getTimetableLessonsGetForIdByLessonId", - "description": "Get a lesson by its ID from the database.", + "operationId": "getTimetableLessonsGetForCohortByCohortId", + "description": "Get lessons for a given cohort from the database.", "tags": [ "Lesson" ], "parameters": [ { - "name": "lessonId", + "name": "cohortId", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } + }, + { + "name": "timetableId", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + }, + "allowEmptyValue": true, + "allowReserved": true } ], "responses": { @@ -34855,177 +36730,38 @@ "content": { "application/json": { "schema": { - "anyOf": [ - { - "type": "object", - "properties": { - "classrooms": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "short": { - "type": "string" - } - }, - "required": [ - "id", - "name", - "short" - ] - } - }, - "cohorts": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "short": { - "type": "string" - } - }, - "required": [ - "id", - "name", - "short" - ] - } - }, - "day": { - "type": "object", - "properties": { - "createdAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "days": { - "anyOf": [ - { - "type": "array", - "items": { - "type": "string" - } - }, - { - "type": "null" - } - ] - }, - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "short": { - "type": "string" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - } - }, - "required": [ - "createdAt", - "days", - "id", - "name", - "short", - "updatedAt" - ] - }, - "groups": { - "type": "array", - "items": { - "type": "object", - "properties": { - "divisionTag": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "entireClass": { - "type": "boolean" - }, - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - }, - "required": [ - "divisionTag", - "entireClass", - "id", - "name" - ] - } - }, - "groupsIds": { - "type": "array", - "items": { - "type": "string" - } - }, - "id": { - "type": "string" - }, - "period": { - "anyOf": [ - { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "classrooms": { + "type": "array", + "items": { "type": "object", "properties": { - "endTime": { - "type": "string" - }, "id": { "type": "string" }, - "period": { - "type": "number" + "name": { + "type": "string" }, - "startTime": { + "short": { "type": "string" } }, "required": [ - "endTime", "id", - "period", - "startTime" + "name", + "short" ] - }, - { - "type": "null" } - ] - }, - "periodsPerWeek": { - "type": "number" - }, - "subject": { - "anyOf": [ - { + }, + "cohorts": { + "type": "array", + "items": { "type": "object", "properties": { "id": { @@ -35043,47 +36779,162 @@ "name", "short" ] - }, - { - "type": "null" } - ] - }, - "teachers": { - "type": "array", - "items": { + }, + "day": { "type": "object", "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "days": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, "id": { - "type": "string" + "type": "string", + "format": "uuid" }, "name": { "type": "string" }, "short": { "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" } }, "required": [ + "createdAt", + "days", "id", "name", - "short" + "short", + "updatedAt" ] - } - }, - "termDefinitionId": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" + }, + "groups": { + "type": "array", + "items": { + "type": "object", + "properties": { + "divisionTag": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "entireClass": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + } + }, + "required": [ + "divisionTag", + "entireClass", + "id", + "name" + ] } - ] - }, - "weekDefinition": { - "anyOf": [ - { + }, + "groupsIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "id": { + "type": "string", + "format": "uuid" + }, + "period": { + "anyOf": [ + { + "type": "object", + "properties": { + "endTime": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "period": { + "type": "number" + }, + "startTime": { + "type": "string" + } + }, + "required": [ + "endTime", + "id", + "period", + "startTime" + ] + }, + { + "type": "null" + } + ] + }, + "periodsPerWeek": { + "type": "number" + }, + "subject": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + }, + { + "type": "null" + } + ] + }, + "teachers": { + "type": "array", + "items": { "type": "object", "properties": { "id": { @@ -35094,59 +36945,88 @@ }, "short": { "type": "string" - }, - "weeks": { - "type": "array", - "items": { - "type": "string" - } } }, "required": [ "id", "name", - "short", - "weeks" + "short" ] - }, - { - "type": "null" } - ] - }, - "weeksDefinitionId": { - "type": "string" + }, + "termDefinitionId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + }, + "weekDefinition": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + }, + "weeks": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "name", + "short", + "weeks" + ] + }, + { + "type": "null" + } + ] + }, + "weeksDefinitionId": { + "type": "string", + "format": "uuid" + } }, - "substitutionCohortName": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "classrooms", - "cohorts", - "groups", - "groupsIds", - "id", - "period", - "periodsPerWeek", - "subject", - "teachers", - "termDefinitionId", - "weekDefinition", - "weeksDefinitionId", - "substitutionCohortName" - ] + "required": [ + "classrooms", + "cohorts", + "groups", + "groupsIds", + "id", + "period", + "periodsPerWeek", + "subject", + "teachers", + "termDefinitionId", + "weekDefinition", + "weeksDefinitionId" + ] + } }, - { - "type": "null" + "total": { + "type": "number" } + }, + "required": [ + "items", + "total" ] } } @@ -35334,37 +37214,25 @@ } } } - }, - "x-filc_group_name": "Lesson", - "x-filc_type_name": "@unit EnrichedLesson @field(.classrooms, List) @field(.day, DayDefinition) @field(.period, Period) @field(.subject, Subject) @field(.teachers, List)" + } } }, - "/timetable/lessons/getForRoom/{classroomId}": { + "/timetable/lessons/getForId/{lessonId}": { "get": { - "operationId": "getTimetableLessonsGetForRoomByClassroomId", - "description": "Get lessons for a given classroom from the database.", + "operationId": "getTimetableLessonsGetForIdByLessonId", + "description": "Get a lesson by its ID from the database.", "tags": [ "Lesson" ], "parameters": [ { - "name": "classroomId", + "name": "lessonId", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } - }, - { - "name": "timetableId", - "in": "query", - "schema": { - "type": "string", - "format": "uuid" - }, - "allowEmptyValue": true, - "allowReserved": true } ], "responses": { @@ -35373,177 +37241,208 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "classrooms": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" + "anyOf": [ + { + "type": "object", + "properties": { + "classrooms": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } }, - "name": { - "type": "string" + "required": [ + "id", + "name", + "short" + ] + } + }, + "cohorts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } }, - "short": { - "type": "string" - } - }, - "required": [ - "id", - "name", - "short" - ] - } - }, - "cohorts": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "short": { - "type": "string" - } - }, - "required": [ - "id", - "name", - "short" - ] - } - }, - "day": { - "type": "object", - "properties": { - "createdAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "days": { - "anyOf": [ - { - "type": "array", - "items": { - "type": "string" - } - }, - { - "type": "null" - } + "required": [ + "id", + "name", + "short" ] - }, - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "short": { - "type": "string" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" } }, - "required": [ - "createdAt", - "days", - "id", - "name", - "short", - "updatedAt" - ] - }, - "groups": { - "type": "array", - "items": { + "day": { "type": "object", "properties": { - "divisionTag": { + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "days": { "anyOf": [ { - "type": "string" + "type": "array", + "items": { + "type": "string" + } }, { "type": "null" } ] }, - "entireClass": { - "type": "boolean" - }, "id": { - "type": "string" + "type": "string", + "format": "uuid" }, "name": { "type": "string" + }, + "short": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" } }, "required": [ - "divisionTag", - "entireClass", + "createdAt", + "days", "id", - "name" + "name", + "short", + "updatedAt" ] - } - }, - "groupsIds": { - "type": "array", - "items": { - "type": "string" - } - }, - "id": { - "type": "string" - }, - "period": { - "anyOf": [ - { + }, + "groups": { + "type": "array", + "items": { "type": "object", "properties": { - "endTime": { - "type": "string" + "divisionTag": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] }, - "id": { - "type": "string" + "entireClass": { + "type": "boolean" }, - "period": { - "type": "number" + "id": { + "type": "string", + "format": "uuid" }, - "startTime": { + "name": { "type": "string" } }, "required": [ - "endTime", + "divisionTag", + "entireClass", "id", - "period", - "startTime" + "name" ] - }, - { - "type": "null" } - ] - }, - "periodsPerWeek": { - "type": "number" - }, - "subject": { - "anyOf": [ - { + }, + "groupsIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "id": { + "type": "string", + "format": "uuid" + }, + "period": { + "anyOf": [ + { + "type": "object", + "properties": { + "endTime": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "period": { + "type": "number" + }, + "startTime": { + "type": "string" + } + }, + "required": [ + "endTime", + "id", + "period", + "startTime" + ] + }, + { + "type": "null" + } + ] + }, + "periodsPerWeek": { + "type": "number" + }, + "subject": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + }, + { + "type": "null" + } + ] + }, + "teachers": { + "type": "array", + "items": { "type": "object", "properties": { "id": { @@ -35561,96 +37460,88 @@ "name", "short" ] - }, - { - "type": "null" } - ] - }, - "teachers": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" + }, + "termDefinitionId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" }, - "short": { - "type": "string" + { + "type": "null" } - }, - "required": [ - "id", - "name", - "short" ] - } - }, - "termDefinitionId": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "weekDefinition": { - "anyOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "short": { - "type": "string" - }, - "weeks": { - "type": "array", - "items": { + }, + "weekDefinition": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "short": { "type": "string" + }, + "weeks": { + "type": "array", + "items": { + "type": "string" + } } - } + }, + "required": [ + "id", + "name", + "short", + "weeks" + ] }, - "required": [ - "id", - "name", - "short", - "weeks" - ] - }, - { - "type": "null" - } - ] + { + "type": "null" + } + ] + }, + "weeksDefinitionId": { + "type": "string", + "format": "uuid" + }, + "substitutionCohortName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } }, - "weeksDefinitionId": { - "type": "string" - } + "required": [ + "classrooms", + "cohorts", + "groups", + "groupsIds", + "id", + "period", + "periodsPerWeek", + "subject", + "teachers", + "termDefinitionId", + "weekDefinition", + "weeksDefinitionId", + "substitutionCohortName" + ] }, - "required": [ - "classrooms", - "cohorts", - "groups", - "groupsIds", - "id", - "period", - "periodsPerWeek", - "subject", - "teachers", - "termDefinitionId", - "weekDefinition", - "weeksDefinitionId" - ] - } + { + "type": "null" + } + ] } } } @@ -35837,21 +37728,19 @@ } } } - }, - "x-filc_group_name": "Lesson", - "x-filc_type_name": "@listof EnrichedLesson @field(.classrooms, List) @field(.cohorts, List) @field(.day, DayDefinition) @field(.period, Period) @field(.subject, Subject) @field(.teachers, List)" + } } }, - "/timetable/lessons/getForTeacher/{teacherId}": { + "/timetable/lessons/getForRoom/{classroomId}": { "get": { - "operationId": "getTimetableLessonsGetForTeacherByTeacherId", - "description": "Get lessons for a given teacher from the database.", + "operationId": "getTimetableLessonsGetForRoomByClassroomId", + "description": "Get lessons for a given classroom from the database.", "tags": [ "Lesson" ], "parameters": [ { - "name": "teacherId", + "name": "classroomId", "in": "path", "required": true, "schema": { @@ -35876,284 +37765,304 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "classrooms": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "short": { - "type": "string" - } - }, - "required": [ - "id", - "name", - "short" - ] - } - }, - "cohorts": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "short": { - "type": "string" - } - }, - "required": [ - "id", - "name", - "short" - ] - } - }, - "day": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { "type": "object", "properties": { - "createdAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "days": { - "anyOf": [ - { - "type": "array", - "items": { + "classrooms": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { "type": "string" } }, - { - "type": "null" - } - ] - }, - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "short": { - "type": "string" + "required": [ + "id", + "name", + "short" + ] + } }, - "updatedAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - } - }, - "required": [ - "createdAt", - "days", - "id", - "name", - "short", - "updatedAt" - ] - }, - "groups": { - "type": "array", - "items": { - "type": "object", - "properties": { - "divisionTag": { - "anyOf": [ - { + "cohorts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "string" }, - { - "type": "null" + "name": { + "type": "string" + }, + "short": { + "type": "string" } + }, + "required": [ + "id", + "name", + "short" ] - }, - "entireClass": { - "type": "boolean" - }, - "id": { - "type": "string" - }, - "name": { - "type": "string" } }, - "required": [ - "divisionTag", - "entireClass", - "id", - "name" - ] - } - }, - "groupsIds": { - "type": "array", - "items": { - "type": "string" - } - }, - "id": { - "type": "string" - }, - "period": { - "anyOf": [ - { + "day": { "type": "object", "properties": { - "endTime": { - "type": "string" - }, - "id": { - "type": "string" + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" }, - "period": { - "type": "number" + "days": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] }, - "startTime": { - "type": "string" - } - }, - "required": [ - "endTime", - "id", - "period", - "startTime" - ] - }, - { - "type": "null" - } - ] - }, - "periodsPerWeek": { - "type": "number" - }, - "subject": { - "anyOf": [ - { - "type": "object", - "properties": { "id": { - "type": "string" + "type": "string", + "format": "uuid" }, "name": { "type": "string" }, "short": { "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" } }, "required": [ + "createdAt", + "days", "id", "name", - "short" + "short", + "updatedAt" ] }, - { - "type": "null" - } - ] - }, - "teachers": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "short": { - "type": "string" + "groups": { + "type": "array", + "items": { + "type": "object", + "properties": { + "divisionTag": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "entireClass": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + } + }, + "required": [ + "divisionTag", + "entireClass", + "id", + "name" + ] } }, - "required": [ - "id", - "name", - "short" - ] - } - }, - "termDefinitionId": { - "anyOf": [ - { - "type": "string" + "groupsIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } }, - { - "type": "null" - } - ] - }, - "weekDefinition": { - "anyOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" + "id": { + "type": "string", + "format": "uuid" + }, + "period": { + "anyOf": [ + { + "type": "object", + "properties": { + "endTime": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "period": { + "type": "number" + }, + "startTime": { + "type": "string" + } + }, + "required": [ + "endTime", + "id", + "period", + "startTime" + ] }, - "short": { - "type": "string" + { + "type": "null" + } + ] + }, + "periodsPerWeek": { + "type": "number" + }, + "subject": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] }, - "weeks": { - "type": "array", - "items": { + { + "type": "null" + } + ] + }, + "teachers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { "type": "string" } + }, + "required": [ + "id", + "name", + "short" + ] + } + }, + "termDefinitionId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" } - }, - "required": [ - "id", - "name", - "short", - "weeks" ] }, - { - "type": "null" + "weekDefinition": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + }, + "weeks": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "name", + "short", + "weeks" + ] + }, + { + "type": "null" + } + ] + }, + "weeksDefinitionId": { + "type": "string", + "format": "uuid" } + }, + "required": [ + "classrooms", + "cohorts", + "groups", + "groupsIds", + "id", + "period", + "periodsPerWeek", + "subject", + "teachers", + "termDefinitionId", + "weekDefinition", + "weeksDefinitionId" ] - }, - "weeksDefinitionId": { - "type": "string" } }, - "required": [ - "classrooms", - "cohorts", - "groups", - "groupsIds", - "id", - "period", - "periodsPerWeek", - "subject", - "teachers", - "termDefinitionId", - "weekDefinition", - "weeksDefinitionId" - ] - } + "total": { + "type": "number" + } + }, + "required": [ + "items", + "total" + ] } } } @@ -36340,249 +38249,222 @@ } } } - }, - "x-filc_group_name": "Lesson", - "x-filc_type_name": "@listof EnrichedLesson @field(.classrooms, List) @field(.cohorts, List) @field(.day, DayDefinition) @field(.period, Period) @field(.subject, Subject) @field(.teachers, List)" + } } }, - "/timetable/lessons/getForTeachers": { - "post": { - "operationId": "postTimetableLessonsGetForTeachers", - "description": "Get lessons for multiple teachers in a single request.", + "/timetable/lessons/getForTeacher/{teacherId}": { + "get": { + "operationId": "getTimetableLessonsGetForTeacherByTeacherId", + "description": "Get lessons for a given teacher from the database.", "tags": [ "Lesson" ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "teacherIds": { - "type": "array", - "minItems": 1, - "items": { - "type": "string", - "format": "uuid" - } - } - }, - "required": [ - "teacherIds" - ] - } + "parameters": [ + { + "name": "teacherId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" } + }, + { + "name": "timetableId", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + }, + "allowEmptyValue": true, + "allowReserved": true } - }, + ], "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "lessons": { - "type": "array", - "items": { - "type": "object", - "properties": { - "classrooms": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "short": { - "type": "string" - } + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "classrooms": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" }, - "required": [ - "id", - "name", - "short" - ] - } - }, - "cohorts": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + } + }, + "cohorts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + } + }, + "day": { + "type": "object", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "days": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } }, - "short": { - "type": "string" + { + "type": "null" } - }, - "required": [ - "id", - "name", - "short" ] + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" } }, - "day": { + "required": [ + "createdAt", + "days", + "id", + "name", + "short", + "updatedAt" + ] + }, + "groups": { + "type": "array", + "items": { "type": "object", "properties": { - "createdAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "days": { + "divisionTag": { "anyOf": [ { - "type": "array", - "items": { - "type": "string" - } + "type": "string" }, { "type": "null" } ] }, + "entireClass": { + "type": "boolean" + }, "id": { - "type": "string" + "type": "string", + "format": "uuid" }, "name": { "type": "string" - }, - "short": { - "type": "string" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" } }, "required": [ - "createdAt", - "days", + "divisionTag", + "entireClass", "id", - "name", - "short", - "updatedAt" + "name" ] - }, - "groups": { - "type": "array", - "items": { - "type": "object", - "properties": { - "divisionTag": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "entireClass": { - "type": "boolean" + } + }, + "groupsIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "id": { + "type": "string", + "format": "uuid" + }, + "period": { + "anyOf": [ + { + "type": "object", + "properties": { + "endTime": { + "type": "string" }, "id": { - "type": "string" + "type": "string", + "format": "uuid" }, - "name": { + "period": { + "type": "number" + }, + "startTime": { "type": "string" } }, "required": [ - "divisionTag", - "entireClass", + "endTime", "id", - "name" + "period", + "startTime" ] + }, + { + "type": "null" } - }, - "groupsIds": { - "type": "array", - "items": { - "type": "string" - } - }, - "id": { - "type": "string" - }, - "period": { - "anyOf": [ - { - "type": "object", - "properties": { - "endTime": { - "type": "string" - }, - "id": { - "type": "string" - }, - "period": { - "type": "number" - }, - "startTime": { - "type": "string" - } - }, - "required": [ - "endTime", - "id", - "period", - "startTime" - ] - }, - { - "type": "null" - } - ] - }, - "periodsPerWeek": { - "type": "number" - }, - "subject": { - "anyOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "short": { - "type": "string" - } - }, - "required": [ - "id", - "name", - "short" - ] - }, - { - "type": "null" - } - ] - }, - "teachers": { - "type": "array", - "items": { + ] + }, + "periodsPerWeek": { + "type": "number" + }, + "subject": { + "anyOf": [ + { "type": "object", "properties": { "id": { @@ -36600,81 +38482,108 @@ "name", "short" ] + }, + { + "type": "null" } - }, - "termDefinitionId": { - "anyOf": [ - { + ] + }, + "teachers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "string" }, - { - "type": "null" + "name": { + "type": "string" + }, + "short": { + "type": "string" } + }, + "required": [ + "id", + "name", + "short" ] - }, - "weekDefinition": { - "anyOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "short": { + } + }, + "termDefinitionId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + }, + "weekDefinition": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + }, + "weeks": { + "type": "array", + "items": { "type": "string" - }, - "weeks": { - "type": "array", - "items": { - "type": "string" - } } - }, - "required": [ - "id", - "name", - "short", - "weeks" - ] + } }, - { - "type": "null" - } - ] - }, - "weeksDefinitionId": { - "type": "string" - } + "required": [ + "id", + "name", + "short", + "weeks" + ] + }, + { + "type": "null" + } + ] }, - "required": [ - "classrooms", - "cohorts", - "groups", - "groupsIds", - "id", - "period", - "periodsPerWeek", - "subject", - "teachers", - "termDefinitionId", - "weekDefinition", - "weeksDefinitionId" - ] - } - }, - "teacherId": { - "type": "string", - "format": "uuid" + "weeksDefinitionId": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "classrooms", + "cohorts", + "groups", + "groupsIds", + "id", + "period", + "periodsPerWeek", + "subject", + "teachers", + "termDefinitionId", + "weekDefinition", + "weeksDefinitionId" + ] } }, - "required": [ - "lessons", - "teacherId" - ] - } + "total": { + "type": "number" + } + }, + "required": [ + "items", + "total" + ] } } } @@ -36861,15 +38770,13 @@ } } } - }, - "x-filc_group_name": "Lesson", - "x-filc_type_name": "@listof TeacherLessonsBatchResult @field(.teacherId, String) @field(.lessons, List)" + } } }, - "/timetable/lessons/getSubstitutionCandidates": { + "/timetable/lessons/getForTeachers": { "post": { - "operationId": "postTimetableLessonsGetSubstitutionCandidates", - "description": "Get available lessons and substitute teacher candidates for substitution editing in one request.", + "operationId": "postTimetableLessonsGetForTeachers", + "description": "Get lessons for multiple teachers in a single request.", "tags": [ "Lesson" ], @@ -36880,23 +38787,6 @@ "schema": { "type": "object", "properties": { - "date": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "missingTeacherId": { - "type": "string", - "format": "uuid" - }, - "selectedLessonIds": { - "type": "array", - "items": { - "type": "string", - "minLength": 1 - }, - "default": [] - }, "teacherIds": { "type": "array", "minItems": 1, @@ -36907,8 +38797,6 @@ } }, "required": [ - "date", - "missingTeacherId", "teacherIds" ] } @@ -36923,175 +38811,738 @@ "schema": { "type": "object", "properties": { - "availableLessons": { + "items": { "type": "array", "items": { "type": "object", "properties": { - "classrooms": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "short": { - "type": "string" - } - }, - "required": [ - "id", - "name", - "short" - ] - } - }, - "cohorts": { + "lessons": { "type": "array", "items": { "type": "object", "properties": { - "id": { - "type": "string" + "classrooms": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + } }, - "name": { - "type": "string" + "cohorts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + } }, - "short": { - "type": "string" - } - }, - "required": [ - "id", - "name", - "short" - ] - } - }, - "day": { - "type": "object", - "properties": { - "createdAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "days": { - "anyOf": [ - { - "type": "array", - "items": { + "day": { + "type": "object", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "days": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "short": { "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" } }, - { - "type": "null" + "required": [ + "createdAt", + "days", + "id", + "name", + "short", + "updatedAt" + ] + }, + "groups": { + "type": "array", + "items": { + "type": "object", + "properties": { + "divisionTag": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "entireClass": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + } + }, + "required": [ + "divisionTag", + "entireClass", + "id", + "name" + ] } - ] - }, - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "short": { - "type": "string" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - } - }, - "required": [ - "createdAt", - "days", - "id", - "name", - "short", - "updatedAt" - ] - }, - "groups": { - "type": "array", - "items": { - "type": "object", - "properties": { - "divisionTag": { + }, + "groupsIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "id": { + "type": "string", + "format": "uuid" + }, + "period": { "anyOf": [ { - "type": "string" + "type": "object", + "properties": { + "endTime": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "period": { + "type": "number" + }, + "startTime": { + "type": "string" + } + }, + "required": [ + "endTime", + "id", + "period", + "startTime" + ] }, { "type": "null" } ] }, - "entireClass": { - "type": "boolean" + "periodsPerWeek": { + "type": "number" }, - "id": { - "type": "string" + "subject": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + }, + { + "type": "null" + } + ] }, - "name": { - "type": "string" + "teachers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + } + }, + "termDefinitionId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + }, + "weekDefinition": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + }, + "weeks": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "name", + "short", + "weeks" + ] + }, + { + "type": "null" + } + ] + }, + "weeksDefinitionId": { + "type": "string", + "format": "uuid" } }, "required": [ - "divisionTag", - "entireClass", + "classrooms", + "cohorts", + "groups", + "groupsIds", "id", - "name" + "period", + "periodsPerWeek", + "subject", + "teachers", + "termDefinitionId", + "weekDefinition", + "weeksDefinitionId" ] } }, - "groupsIds": { - "type": "array", - "items": { - "type": "string" - } - }, - "id": { - "type": "string" - }, - "period": { - "anyOf": [ - { - "type": "object", - "properties": { - "endTime": { - "type": "string" - }, - "id": { - "type": "string" - }, - "period": { - "type": "number" - }, - "startTime": { - "type": "string" - } - }, - "required": [ - "endTime", - "id", - "period", - "startTime" - ] - }, - { - "type": "null" - } - ] - }, - "periodsPerWeek": { - "type": "number" - }, + "teacherId": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "lessons", + "teacherId" + ] + } + }, + "total": { + "type": "number" + } + }, + "required": [ + "items", + "total" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + } + } + }, + "/timetable/lessons/getSubstitutionCandidates": { + "post": { + "operationId": "postTimetableLessonsGetSubstitutionCandidates", + "description": "Get available lessons and substitute teacher candidates for substitution editing in one request.", + "tags": [ + "Lesson" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "date": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "missingTeacherId": { + "type": "string", + "format": "uuid" + }, + "selectedLessonIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "default": [] + }, + "teacherIds": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "format": "uuid" + } + } + }, + "required": [ + "date", + "missingTeacherId", + "teacherIds" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "availableLessons": { + "type": "array", + "items": { + "type": "object", + "properties": { + "classrooms": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + } + }, + "cohorts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + } + }, + "day": { + "type": "object", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "days": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "createdAt", + "days", + "id", + "name", + "short", + "updatedAt" + ] + }, + "groups": { + "type": "array", + "items": { + "type": "object", + "properties": { + "divisionTag": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "entireClass": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + } + }, + "required": [ + "divisionTag", + "entireClass", + "id", + "name" + ] + } + }, + "groupsIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "id": { + "type": "string", + "format": "uuid" + }, + "period": { + "anyOf": [ + { + "type": "object", + "properties": { + "endTime": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "period": { + "type": "number" + }, + "startTime": { + "type": "string" + } + }, + "required": [ + "endTime", + "id", + "period", + "startTime" + ] + }, + { + "type": "null" + } + ] + }, + "periodsPerWeek": { + "type": "number" + }, "subject": { "anyOf": [ { @@ -37143,7 +39594,8 @@ "termDefinitionId": { "anyOf": [ { - "type": "string" + "type": "string", + "format": "uuid" }, { "type": "null" @@ -37156,7 +39608,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "format": "uuid" }, "name": { "type": "string" @@ -37184,7 +39637,8 @@ ] }, "weeksDefinitionId": { - "type": "string" + "type": "string", + "format": "uuid" } }, "required": [ @@ -37274,7 +39728,8 @@ ] }, "id": { - "type": "string" + "type": "string", + "format": "uuid" }, "name": { "type": "string" @@ -37316,7 +39771,8 @@ "type": "boolean" }, "id": { - "type": "string" + "type": "string", + "format": "uuid" }, "name": { "type": "string" @@ -37333,11 +39789,13 @@ "groupsIds": { "type": "array", "items": { - "type": "string" + "type": "string", + "format": "uuid" } }, "id": { - "type": "string" + "type": "string", + "format": "uuid" }, "period": { "anyOf": [ @@ -37348,7 +39806,8 @@ "type": "string" }, "id": { - "type": "string" + "type": "string", + "format": "uuid" }, "period": { "type": "number" @@ -37423,7 +39882,8 @@ "termDefinitionId": { "anyOf": [ { - "type": "string" + "type": "string", + "format": "uuid" }, { "type": "null" @@ -37436,7 +39896,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "format": "uuid" }, "name": { "type": "string" @@ -37464,7 +39925,8 @@ ] }, "weeksDefinitionId": { - "type": "string" + "type": "string", + "format": "uuid" } }, "required": [ @@ -37501,7 +39963,8 @@ "type": "string" }, "id": { - "type": "string" + "type": "string", + "format": "uuid" }, "lastName": { "type": "string" @@ -37717,9 +40180,7 @@ } } } - }, - "x-filc_group_name": "Lesson", - "x-filc_type_name": "@unit SubstitutionCandidatesResult @field(.availableLessons, List) @field(.parallelLessons, List) @field(.substituteCandidates, List)" + } } }, "/timetable/substitutions": { @@ -37755,13 +40216,15 @@ "type": "array", "minItems": 1, "items": { - "type": "string" + "type": "string", + "format": "uuid" } }, "substituter": { "anyOf": [ { - "type": "string" + "type": "string", + "format": "uuid" }, { "type": "null" @@ -37801,12 +40264,14 @@ "x-native-type": "date" }, "id": { - "type": "string" + "type": "string", + "format": "uuid" }, "substituter": { "anyOf": [ { - "type": "string" + "type": "string", + "format": "uuid" }, { "type": "null" @@ -38007,9 +40472,6 @@ } } }, - "x-filc_group_name": "Substitution", - "x-filc_type_name": "@unit Substitution", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -38028,244 +40490,305 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "lessons": { - "type": "array", - "items": { - "type": "object", - "properties": { - "classrooms": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "short": { - "type": "string" - } - }, - "required": [ - "id", - "name", - "short" - ] - } - }, - "cohorts": { - "type": "array", - "items": { - "type": "string" - } - }, - "day": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "lessons": { + "type": "array", + "items": { "type": "object", "properties": { - "createdAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "days": { - "anyOf": [ - { - "type": "array", - "items": { + "classrooms": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { "type": "string" } }, - { - "type": "null" - } - ] - }, - "id": { - "type": "string" - }, - "name": { - "type": "string" + "required": [ + "id", + "name", + "short" + ] + } }, - "short": { - "type": "string" + "cohorts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + } }, - "updatedAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - } - }, - "required": [ - "createdAt", - "days", - "id", - "name", - "short", - "updatedAt" - ] - }, - "id": { - "type": "string" - }, - "period": { - "anyOf": [ - { + "day": { "type": "object", "properties": { - "endTime": { - "type": "string" + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" }, - "id": { - "type": "string" + "days": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] }, - "period": { - "type": "number" - }, - "startTime": { - "type": "string" - } - }, - "required": [ - "endTime", - "id", - "period", - "startTime" - ] - }, - { - "type": "null" - } - ] - }, - "periodsPerWeek": { - "type": "number" - }, - "subject": { - "anyOf": [ - { - "type": "object", - "properties": { "id": { - "type": "string" + "type": "string", + "format": "uuid" }, "name": { "type": "string" }, "short": { "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" } }, "required": [ + "createdAt", + "days", "id", "name", - "short" + "short", + "updatedAt" ] }, - { - "type": "null" - } - ] - }, - "teachers": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "short": { - "type": "string" + "groups": { + "type": "array", + "items": { + "type": "object", + "properties": { + "divisionTag": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "entireClass": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + } + }, + "required": [ + "divisionTag", + "entireClass", + "id", + "name" + ] } }, - "required": [ - "id", - "name", - "short" - ] - } - }, - "termDefinitionId": { - "anyOf": [ - { - "type": "string" + "groupsIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } }, - { - "type": "null" + "id": { + "type": "string", + "format": "uuid" + }, + "period": { + "anyOf": [ + { + "type": "object", + "properties": { + "endTime": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "period": { + "type": "number" + }, + "startTime": { + "type": "string" + } + }, + "required": [ + "endTime", + "id", + "period", + "startTime" + ] + }, + { + "type": "null" + } + ] + }, + "periodsPerWeek": { + "type": "number" + }, + "subject": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + }, + { + "type": "null" + } + ] + }, + "teachers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + } + }, + "termDefinitionId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + }, + "weekDefinition": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + }, + "weeks": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "name", + "short", + "weeks" + ] + }, + { + "type": "null" + } + ] + }, + "weeksDefinitionId": { + "type": "string", + "format": "uuid" } + }, + "required": [ + "classrooms", + "cohorts", + "groups", + "groupsIds", + "id", + "period", + "periodsPerWeek", + "subject", + "teachers", + "termDefinitionId", + "weekDefinition", + "weeksDefinitionId" ] - }, - "weeksDefinitionId": { - "type": "string" } }, - "required": [ - "classrooms", - "cohorts", - "id", - "period", - "periodsPerWeek", - "subject", - "teachers", - "termDefinitionId", - "weeksDefinitionId" - ] - } - }, - "substitution": { - "type": "object", - "properties": { - "comment": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "date": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "id": { - "type": "string" - }, - "substituter": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "comment", - "date", - "id", - "substituter" - ] - }, - "teacher": { - "anyOf": [ - { + "substitution": { "type": "object", "properties": { - "email": { + "comment": { "anyOf": [ { "type": "string" @@ -38275,31 +40798,16 @@ } ] }, - "firstName": { - "type": "string" - }, - "gender": { - "anyOf": [ - { - "type": "string", - "maxLength": 1, - "pattern": "^[01]+$" - }, - { - "type": "null" - } - ] + "date": { + "type": "string", + "format": "date-time", + "x-native-type": "date" }, "id": { - "type": "string" - }, - "lastName": { - "type": "string" - }, - "short": { - "type": "string" + "type": "string", + "format": "uuid" }, - "userId": { + "substituter": { "anyOf": [ { "type": "string", @@ -38312,27 +40820,95 @@ } }, "required": [ - "email", - "firstName", - "gender", + "comment", + "date", "id", - "lastName", - "short", - "userId" + "substituter" ] }, - { - "type": "null" + "teacher": { + "anyOf": [ + { + "type": "object", + "properties": { + "email": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "firstName": { + "type": "string" + }, + "gender": { + "anyOf": [ + { + "type": "string", + "maxLength": 1, + "pattern": "^[01]+$" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string", + "format": "uuid" + }, + "lastName": { + "type": "string" + }, + "short": { + "type": "string" + }, + "userId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "email", + "firstName", + "gender", + "id", + "lastName", + "short", + "userId" + ] + }, + { + "type": "null" + } + ] } + }, + "required": [ + "lessons", + "substitution", + "teacher" ] } }, - "required": [ - "lessons", - "substitution", - "teacher" - ] - } + "total": { + "type": "number" + } + }, + "required": [ + "items", + "total" + ] } } } @@ -38520,9 +41096,6 @@ } } }, - "x-filc_group_name": "Substitution", - "x-filc_type_name": "@listof SubstitutionWithRelations @field(.substitution, Substitution) @field(.teacher, Teacher) @field(.lessons, List)", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -38557,7 +41130,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "format": "uuid" } }, "required": [ @@ -38750,9 +41324,6 @@ } } }, - "x-filc_group_name": "Substitution", - "x-filc_type_name": "@nodata", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -38803,7 +41374,8 @@ { "type": "array", "items": { - "type": "string" + "type": "string", + "format": "uuid" } }, { @@ -38814,7 +41386,8 @@ "substituter": { "anyOf": [ { - "type": "string" + "type": "string", + "format": "uuid" }, { "type": "null" @@ -38853,12 +41426,14 @@ "x-native-type": "date" }, "id": { - "type": "string" + "type": "string", + "format": "uuid" }, "substituter": { "anyOf": [ { - "type": "string" + "type": "string", + "format": "uuid" }, { "type": "null" @@ -39059,9 +41634,6 @@ } } }, - "x-filc_group_name": "Substitution", - "x-filc_type_name": "@unit Substitution", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -39293,9 +41865,6 @@ } } }, - "x-filc_group_name": "Substitution", - "x-filc_type_name": "Export substitutions as CSV", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -39403,12 +41972,14 @@ "x-native-type": "date" }, "id": { - "type": "string" + "type": "string", + "format": "uuid" }, "substituter": { "anyOf": [ { - "type": "string" + "type": "string", + "format": "uuid" }, { "type": "null" @@ -39609,9 +42180,6 @@ } } }, - "x-filc_group_name": "Substitution", - "x-filc_type_name": "@unit Substitution", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -39626,349 +42194,6 @@ "tags": [ "Substitution" ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "lessons": { - "type": "array", - "items": { - "type": "string" - } - }, - "substitution": { - "type": "object", - "properties": { - "comment": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "date": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "id": { - "type": "string" - }, - "substituter": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "comment", - "date", - "id", - "substituter" - ] - }, - "teacher": { - "anyOf": [ - { - "type": "object", - "properties": { - "email": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "firstName": { - "type": "string" - }, - "gender": { - "anyOf": [ - { - "type": "string", - "maxLength": 1, - "pattern": "^[01]+$" - }, - { - "type": "null" - } - ] - }, - "id": { - "type": "string" - }, - "lastName": { - "type": "string" - }, - "short": { - "type": "string" - }, - "userId": { - "anyOf": [ - { - "type": "string", - "format": "uuid" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "email", - "firstName", - "gender", - "id", - "lastName", - "short", - "userId" - ] - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "lessons", - "substitution", - "teacher" - ] - } - } - } - } - }, - "400": { - "description": "400", - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "type": "string" - }, - "message": { - "type": "string" - }, - "status": { - "type": "integer" - } - }, - "required": [ - "code", - "message", - "status" - ], - "type": "object" - } - } - } - }, - "401": { - "description": "401", - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "type": "string" - }, - "message": { - "type": "string" - }, - "status": { - "type": "integer" - } - }, - "required": [ - "code", - "message", - "status" - ], - "type": "object" - } - } - } - }, - "403": { - "description": "403", - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "type": "string" - }, - "message": { - "type": "string" - }, - "status": { - "type": "integer" - } - }, - "required": [ - "code", - "message", - "status" - ], - "type": "object" - } - } - } - }, - "404": { - "description": "404", - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "type": "string" - }, - "message": { - "type": "string" - }, - "status": { - "type": "integer" - } - }, - "required": [ - "code", - "message", - "status" - ], - "type": "object" - } - } - } - }, - "409": { - "description": "409", - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "type": "string" - }, - "message": { - "type": "string" - }, - "status": { - "type": "integer" - } - }, - "required": [ - "code", - "message", - "status" - ], - "type": "object" - } - } - } - }, - "429": { - "description": "429", - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "type": "string" - }, - "message": { - "type": "string" - }, - "status": { - "type": "integer" - } - }, - "required": [ - "code", - "message", - "status" - ], - "type": "object" - } - } - } - }, - "500": { - "description": "500", - "content": { - "application/json": { - "schema": { - "properties": { - "code": { - "type": "string" - }, - "message": { - "type": "string" - }, - "status": { - "type": "integer" - } - }, - "required": [ - "code", - "message", - "status" - ], - "type": "object" - } - } - } - } - }, - "x-filc_group_name": "Substitution", - "x-filc_type_name": "@listof SubstitutionWithRelations @field(.substitution, Substitution) @field(.teacher, Teacher) @field(.lessons, List)", - "x-filc_auth": "true", - "security": [ - { - "sessionAuth": [] - } - ] - } - }, - "/timetable/substitutions/cohort/{cohortId}": { - "get": { - "operationId": "getTimetableSubstitutionsCohortByCohortId", - "description": "Get relevant substitutions for a given cohort from the database.", - "tags": [ - "Substitution" - ], - "parameters": [ - { - "name": "cohortId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], "responses": { "200": { "description": "OK", @@ -39977,10 +42202,7 @@ "schema": { "type": "object", "properties": { - "cohortId": { - "type": "string" - }, - "substitutions": { + "items": { "type": "array", "items": { "type": "object", @@ -39988,7 +42210,8 @@ "lessons": { "type": "array", "items": { - "type": "string" + "type": "string", + "format": "uuid" } }, "substitution": { @@ -40010,12 +42233,14 @@ "x-native-type": "date" }, "id": { - "type": "string" + "type": "string", + "format": "uuid" }, "substituter": { "anyOf": [ { - "type": "string" + "type": "string", + "format": "uuid" }, { "type": "null" @@ -40061,7 +42286,8 @@ ] }, "id": { - "type": "string" + "type": "string", + "format": "uuid" }, "lastName": { "type": "string" @@ -40103,11 +42329,14 @@ "teacher" ] } + }, + "total": { + "type": "number" } }, "required": [ - "cohortId", - "substitutions" + "items", + "total" ] } } @@ -40296,9 +42525,6 @@ } } }, - "x-filc_group_name": "Substitution", - "x-filc_type_name": "@unit SubstitutionsByCohort @field(.substitutions, List)", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -40306,82 +42532,24 @@ ] } }, - "/timetable/movedLessons": { - "post": { - "operationId": "postTimetableMovedLessons", - "description": "Create a moved lesson.", + "/timetable/substitutions/cohort/{cohortId}": { + "get": { + "operationId": "getTimetableSubstitutionsCohortByCohortId", + "description": "Get relevant substitutions for a given cohort from the database.", "tags": [ - "Moved Lesson" + "Substitution" ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "comment": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "date": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "lessonIds": { - "type": "array", - "minItems": 1, - "items": { - "type": "string", - "format": "uuid" - } - }, - "room": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "startingDay": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "startingPeriod": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "date", - "lessonIds" - ] - } + "parameters": [ + { + "name": "cohortId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" } } - }, + ], "responses": { "200": { "description": "OK", @@ -40390,62 +42558,482 @@ "schema": { "type": "object", "properties": { - "comment": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "date": { + "cohortId": { "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "id": { - "type": "string" - }, - "room": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "startingDay": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] + "format": "uuid" }, - "startingPeriod": { - "anyOf": [ - { - "type": "string" + "substitutions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "lessons": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "substitution": { + "type": "object", + "properties": { + "comment": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "date": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "substituter": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "comment", + "date", + "id", + "substituter" + ] + }, + "teacher": { + "anyOf": [ + { + "type": "object", + "properties": { + "email": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "firstName": { + "type": "string" + }, + "gender": { + "anyOf": [ + { + "type": "string", + "maxLength": 1, + "pattern": "^[01]+$" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string", + "format": "uuid" + }, + "lastName": { + "type": "string" + }, + "short": { + "type": "string" + }, + "userId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "email", + "firstName", + "gender", + "id", + "lastName", + "short", + "userId" + ] + }, + { + "type": "null" + } + ] + } }, - { - "type": "null" - } - ] + "required": [ + "lessons", + "substitution", + "teacher" + ] + } } }, "required": [ - "comment", - "date", - "id", - "room", - "startingDay", - "startingPeriod" + "cohortId", + "substitutions" + ] + } + } + } + }, + "400": { + "description": "400", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "401": { + "description": "401", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "403": { + "description": "403", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "429": { + "description": "429", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + }, + "500": { + "description": "500", + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": [ + "code", + "message", + "status" + ], + "type": "object" + } + } + } + } + }, + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/timetable/movedLessons": { + "post": { + "operationId": "postTimetableMovedLessons", + "description": "Create a moved lesson.", + "tags": [ + "Moved Lesson" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "comment": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "date": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "lessonIds": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "format": "uuid" + } + }, + "room": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "startingDay": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + }, + "startingPeriod": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "date", + "lessonIds" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "comment": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "date": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "room": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "startingDay": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + }, + "startingPeriod": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "comment", + "date", + "id", + "room", + "startingDay", + "startingPeriod" ] } } @@ -40634,9 +43222,6 @@ } } }, - "x-filc_group_name": "MovedLesson", - "x-filc_type_name": "@unit MovedLesson", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -40655,457 +43240,573 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "classroom": { - "anyOf": [ - { - "type": "object", - "properties": { - "building_id": { - "type": "string" - }, - "capacity": { - "anyOf": [ - { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "classroom": { + "anyOf": [ + { + "type": "object", + "properties": { + "building_id": { + "type": "string" + }, + "capacity": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mapped": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "rotation": { + "type": "number" + }, + "short": { + "type": "string" + }, + "size_x": { + "type": "number" + }, + "size_y": { + "type": "number" + }, + "size_z": { + "type": "number" + }, + "storey": { "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, - { - "type": "null" - } - ] - }, - "description": { - "type": "string" - }, - "id": { - "type": "string" - }, - "mapped": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "rotation": { - "type": "number" - }, - "short": { - "type": "string" - }, - "size_x": { - "type": "number" - }, - "size_y": { - "type": "number" - }, - "size_z": { - "type": "number" - }, - "storey": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - "type_id": { - "anyOf": [ - { - "type": "string" + "type_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "x": { - "type": "number" - }, - "y": { - "type": "number" - } - }, - "required": [ - "building_id", - "capacity", - "description", - "id", - "mapped", - "name", - "rotation", - "short", - "size_x", - "size_y", - "size_z", - "storey", - "type_id", - "x", - "y" - ] - }, - { - "type": "null" - } - ] - }, - "dayDefinition": { - "anyOf": [ - { - "type": "object", - "properties": { - "createdAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "days": { - "anyOf": [ - { - "type": "array", - "items": { - "type": "string" - } + "x": { + "type": "number" }, - { - "type": "null" + "y": { + "type": "number" } + }, + "required": [ + "building_id", + "capacity", + "description", + "id", + "mapped", + "name", + "rotation", + "short", + "size_x", + "size_y", + "size_z", + "storey", + "type_id", + "x", + "y" ] }, - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "short": { - "type": "string" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" + { + "type": "null" } - }, - "required": [ - "createdAt", - "days", - "id", - "name", - "short", - "updatedAt" ] }, - { - "type": "null" - } - ] - }, - "lessons": { - "type": "array", - "items": { - "type": "object", - "properties": { - "classrooms": { - "type": "array", - "items": { + "dayDefinition": { + "anyOf": [ + { "type": "object", "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "days": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, "id": { - "type": "string" + "type": "string", + "format": "uuid" }, "name": { "type": "string" }, "short": { "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" } }, "required": [ + "createdAt", + "days", "id", "name", - "short" + "short", + "updatedAt" ] + }, + { + "type": "null" } - }, - "cohorts": { - "type": "array", - "items": { - "type": "string" - } - }, - "day": { + ] + }, + "lessons": { + "type": "array", + "items": { "type": "object", "properties": { - "createdAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "days": { - "anyOf": [ - { - "type": "array", - "items": { + "classrooms": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { "type": "string" } }, - { - "type": "null" - } - ] - }, - "id": { - "type": "string" - }, - "name": { - "type": "string" + "required": [ + "id", + "name", + "short" + ] + } }, - "short": { - "type": "string" + "cohorts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + } }, - "updatedAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - } - }, - "required": [ - "createdAt", - "days", - "id", - "name", - "short", - "updatedAt" - ] - }, - "id": { - "type": "string" - }, - "period": { - "anyOf": [ - { + "day": { "type": "object", "properties": { - "endTime": { - "type": "string" - }, - "id": { - "type": "string" + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" }, - "period": { - "type": "number" + "days": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] }, - "startTime": { - "type": "string" - } - }, - "required": [ - "endTime", - "id", - "period", - "startTime" - ] - }, - { - "type": "null" - } - ] - }, - "periodsPerWeek": { - "type": "number" - }, - "subject": { - "anyOf": [ - { - "type": "object", - "properties": { "id": { - "type": "string" + "type": "string", + "format": "uuid" }, "name": { "type": "string" }, "short": { "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" } }, "required": [ + "createdAt", + "days", "id", "name", - "short" + "short", + "updatedAt" ] }, - { - "type": "null" - } - ] - }, - "teachers": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "short": { - "type": "string" + "groups": { + "type": "array", + "items": { + "type": "object", + "properties": { + "divisionTag": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "entireClass": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + } + }, + "required": [ + "divisionTag", + "entireClass", + "id", + "name" + ] } }, - "required": [ - "id", - "name", - "short" - ] - } - }, - "termDefinitionId": { - "anyOf": [ - { - "type": "string" + "groupsIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } }, - { - "type": "null" + "id": { + "type": "string", + "format": "uuid" + }, + "period": { + "anyOf": [ + { + "type": "object", + "properties": { + "endTime": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "period": { + "type": "number" + }, + "startTime": { + "type": "string" + } + }, + "required": [ + "endTime", + "id", + "period", + "startTime" + ] + }, + { + "type": "null" + } + ] + }, + "periodsPerWeek": { + "type": "number" + }, + "subject": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + }, + { + "type": "null" + } + ] + }, + "teachers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + } + }, + "termDefinitionId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + }, + "weekDefinition": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + }, + "weeks": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "name", + "short", + "weeks" + ] + }, + { + "type": "null" + } + ] + }, + "weeksDefinitionId": { + "type": "string", + "format": "uuid" } + }, + "required": [ + "classrooms", + "cohorts", + "groups", + "groupsIds", + "id", + "period", + "periodsPerWeek", + "subject", + "teachers", + "termDefinitionId", + "weekDefinition", + "weeksDefinitionId" ] - }, - "weeksDefinitionId": { - "type": "string" } }, - "required": [ - "classrooms", - "cohorts", - "id", - "period", - "periodsPerWeek", - "subject", - "teachers", - "termDefinitionId", - "weeksDefinitionId" - ] - } - }, - "movedLesson": { - "type": "object", - "properties": { - "comment": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "date": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "id": { - "type": "string" - }, - "room": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "startingDay": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "startingPeriod": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "comment", - "date", - "id", - "room", - "startingDay", - "startingPeriod" - ] - }, - "period": { - "anyOf": [ - { + "movedLesson": { "type": "object", "properties": { - "createdAt": { + "comment": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "date": { "type": "string", "format": "date-time", "x-native-type": "date" }, - "endTime": { - "type": "string" - }, "id": { - "type": "string" + "type": "string", + "format": "uuid" }, - "period": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 + "room": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] }, - "startTime": { - "type": "string" + "startingDay": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] }, - "updatedAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" + "startingPeriod": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] } }, "required": [ - "createdAt", - "endTime", + "comment", + "date", "id", - "period", - "startTime", - "updatedAt" + "room", + "startingDay", + "startingPeriod" ] }, - { - "type": "null" + "period": { + "anyOf": [ + { + "type": "object", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "endTime": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "period": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "startTime": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "createdAt", + "endTime", + "id", + "period", + "startTime", + "updatedAt" + ] + }, + { + "type": "null" + } + ] } + }, + "required": [ + "classroom", + "dayDefinition", + "lessons", + "movedLesson", + "period" ] } }, - "required": [ - "classroom", - "dayDefinition", - "lessons", - "movedLesson", - "period" - ] - } + "total": { + "type": "number" + } + }, + "required": [ + "items", + "total" + ] } } } @@ -41292,9 +43993,7 @@ } } } - }, - "x-filc_group_name": "MovedLesson", - "x-filc_type_name": "@listof MovedLessonWithRelations @field(.movedLesson, MovedLesson) @field(.classroom, Classroom) @field(.dayDefinition, DayDefinition) @field(.period, Period) @field(.lessons, List)" + } } }, "/timetable/movedLessons/{id}": { @@ -41324,7 +44023,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "format": "uuid" } }, "required": [ @@ -41517,9 +44217,6 @@ } } }, - "x-filc_group_name": "MovedLesson", - "x-filc_type_name": "@nodata", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -41620,7 +44317,8 @@ "x-native-type": "date" }, "id": { - "type": "string" + "type": "string", + "format": "uuid" }, "room": { "anyOf": [ @@ -41635,7 +44333,8 @@ "startingDay": { "anyOf": [ { - "type": "string" + "type": "string", + "format": "uuid" }, { "type": "null" @@ -41645,7 +44344,8 @@ "startingPeriod": { "anyOf": [ { - "type": "string" + "type": "string", + "format": "uuid" }, { "type": "null" @@ -41848,9 +44548,6 @@ } } }, - "x-filc_group_name": "MovedLesson", - "x-filc_type_name": "@unit MovedLesson", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -42082,9 +44779,6 @@ } } }, - "x-filc_group_name": "MovedLesson", - "x-filc_type_name": "Export moved lessons as CSV", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -42116,388 +44810,549 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "classroom": { - "anyOf": [ - { - "type": "object", - "properties": { - "building_id": { - "type": "string" - }, - "capacity": { - "anyOf": [ - { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "classroom": { + "anyOf": [ + { + "type": "object", + "properties": { + "building_id": { + "type": "string" + }, + "capacity": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mapped": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "rotation": { + "type": "number" + }, + "short": { + "type": "string" + }, + "size_x": { + "type": "number" + }, + "size_y": { + "type": "number" + }, + "size_z": { + "type": "number" + }, + "storey": { "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, - { - "type": "null" - } - ] - }, - "description": { - "type": "string" - }, - "id": { - "type": "string" - }, - "mapped": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "rotation": { - "type": "number" - }, - "short": { - "type": "string" - }, - "size_x": { - "type": "number" - }, - "size_y": { - "type": "number" - }, - "size_z": { - "type": "number" - }, - "storey": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - "type_id": { - "anyOf": [ - { - "type": "string" + "type_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "x": { - "type": "number" - }, - "y": { - "type": "number" - } - }, - "required": [ - "building_id", - "capacity", - "description", - "id", - "mapped", - "name", - "rotation", - "short", - "size_x", - "size_y", - "size_z", - "storey", - "type_id", - "x", - "y" - ] - }, - { - "type": "null" - } - ] - }, - "dayDefinition": { - "anyOf": [ - { - "type": "object", - "properties": { - "createdAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "days": { - "anyOf": [ - { - "type": "array", - "items": { - "type": "string" - } + "x": { + "type": "number" }, - { - "type": "null" + "y": { + "type": "number" } + }, + "required": [ + "building_id", + "capacity", + "description", + "id", + "mapped", + "name", + "rotation", + "short", + "size_x", + "size_y", + "size_z", + "storey", + "type_id", + "x", + "y" ] }, - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "short": { - "type": "string" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" + { + "type": "null" } - }, - "required": [ - "createdAt", - "days", - "id", - "name", - "short", - "updatedAt" ] }, - { - "type": "null" - } - ] - }, - "lessons": { - "type": "array", - "items": { - "type": "object", - "properties": { - "classrooms": { - "type": "array", - "items": { + "dayDefinition": { + "anyOf": [ + { "type": "object", "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "days": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, "id": { - "type": "string" + "type": "string", + "format": "uuid" }, "name": { "type": "string" }, "short": { "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" } }, "required": [ + "createdAt", + "days", "id", "name", - "short" + "short", + "updatedAt" ] + }, + { + "type": "null" } - }, - "cohorts": { - "type": "array", - "items": { - "type": "string" - } - }, - "day": { + ] + }, + "lessons": { + "type": "array", + "items": { "type": "object", "properties": { - "createdAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "days": { - "anyOf": [ - { - "type": "array", - "items": { + "classrooms": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { "type": "string" } }, - { - "type": "null" - } - ] - }, - "id": { - "type": "string" - }, - "name": { - "type": "string" + "required": [ + "id", + "name", + "short" + ] + } }, - "short": { - "type": "string" + "cohorts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + } }, - "updatedAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - } - }, - "required": [ - "createdAt", - "days", - "id", - "name", - "short", - "updatedAt" - ] - }, - "id": { - "type": "string" - }, - "period": { - "anyOf": [ - { + "day": { "type": "object", "properties": { - "endTime": { - "type": "string" + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "days": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] }, "id": { - "type": "string" + "type": "string", + "format": "uuid" }, - "period": { - "type": "number" + "name": { + "type": "string" }, - "startTime": { + "short": { "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" } }, "required": [ - "endTime", + "createdAt", + "days", "id", - "period", - "startTime" + "name", + "short", + "updatedAt" ] }, - { - "type": "null" - } - ] - }, - "periodsPerWeek": { - "type": "number" - }, - "subject": { - "anyOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "string" + "groups": { + "type": "array", + "items": { + "type": "object", + "properties": { + "divisionTag": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "entireClass": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + } }, - "name": { - "type": "string" + "required": [ + "divisionTag", + "entireClass", + "id", + "name" + ] + } + }, + "groupsIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "id": { + "type": "string", + "format": "uuid" + }, + "period": { + "anyOf": [ + { + "type": "object", + "properties": { + "endTime": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "period": { + "type": "number" + }, + "startTime": { + "type": "string" + } + }, + "required": [ + "endTime", + "id", + "period", + "startTime" + ] }, - "short": { - "type": "string" + { + "type": "null" + } + ] + }, + "periodsPerWeek": { + "type": "number" + }, + "subject": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + }, + { + "type": "null" + } + ] + }, + "teachers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + } + }, + "termDefinitionId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + }, + "weekDefinition": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + }, + "weeks": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "name", + "short", + "weeks" + ] + }, + { + "type": "null" } - }, - "required": [ - "id", - "name", - "short" ] }, - { - "type": "null" + "weeksDefinitionId": { + "type": "string", + "format": "uuid" } + }, + "required": [ + "classrooms", + "cohorts", + "groups", + "groupsIds", + "id", + "period", + "periodsPerWeek", + "subject", + "teachers", + "termDefinitionId", + "weekDefinition", + "weeksDefinitionId" ] - }, - "teachers": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { + } + }, + "movedLesson": { + "type": "object", + "properties": { + "comment": { + "anyOf": [ + { "type": "string" }, - "name": { + { + "type": "null" + } + ] + }, + "date": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "room": { + "anyOf": [ + { "type": "string" }, - "short": { - "type": "string" + { + "type": "null" } - }, - "required": [ - "id", - "name", - "short" ] - } - }, - "termDefinitionId": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "weeksDefinitionId": { - "type": "string" - } - }, - "required": [ - "classrooms", - "cohorts", - "id", - "period", - "periodsPerWeek", - "subject", - "teachers", - "termDefinitionId", - "weeksDefinitionId" - ] - } - }, - "movedLesson": { - "type": "object", - "properties": { - "comment": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "date": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "id": { - "type": "string" - }, - "room": { - "anyOf": [ - { - "type": "string" }, - { - "type": "null" - } - ] - }, - "startingDay": { - "anyOf": [ - { - "type": "string" + "startingDay": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] }, - { - "type": "null" + "startingPeriod": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] } + }, + "required": [ + "comment", + "date", + "id", + "room", + "startingDay", + "startingPeriod" ] }, - "startingPeriod": { + "period": { "anyOf": [ { - "type": "string" + "type": "object", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "endTime": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "period": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "startTime": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "createdAt", + "endTime", + "id", + "period", + "startTime", + "updatedAt" + ] }, { "type": "null" @@ -42506,67 +45361,22 @@ } }, "required": [ - "comment", - "date", - "id", - "room", - "startingDay", - "startingPeriod" - ] - }, - "period": { - "anyOf": [ - { - "type": "object", - "properties": { - "createdAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "endTime": { - "type": "string" - }, - "id": { - "type": "string" - }, - "period": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - "startTime": { - "type": "string" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - } - }, - "required": [ - "createdAt", - "endTime", - "id", - "period", - "startTime", - "updatedAt" - ] - }, - { - "type": "null" - } + "classroom", + "dayDefinition", + "lessons", + "movedLesson", + "period" ] } }, - "required": [ - "classroom", - "dayDefinition", - "lessons", - "movedLesson", - "period" - ] - } + "total": { + "type": "number" + } + }, + "required": [ + "items", + "total" + ] } } } @@ -42753,9 +45563,7 @@ } } } - }, - "x-filc_group_name": "MovedLesson", - "x-filc_type_name": "@listof MovedLessonWithRelations @field(.movedLesson, MovedLesson) @field(.classroom, Classroom) @field(.dayDefinition, DayDefinition) @field(.period, Period) @field(.lessons, List)" + } } }, "/timetable/movedLessons/relevant": { @@ -42771,388 +45579,549 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "classroom": { - "anyOf": [ - { - "type": "object", - "properties": { - "building_id": { - "type": "string" - }, - "capacity": { - "anyOf": [ - { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "classroom": { + "anyOf": [ + { + "type": "object", + "properties": { + "building_id": { + "type": "string" + }, + "capacity": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mapped": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "rotation": { + "type": "number" + }, + "short": { + "type": "string" + }, + "size_x": { + "type": "number" + }, + "size_y": { + "type": "number" + }, + "size_z": { + "type": "number" + }, + "storey": { "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, - { - "type": "null" - } - ] - }, - "description": { - "type": "string" - }, - "id": { - "type": "string" - }, - "mapped": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "rotation": { - "type": "number" - }, - "short": { - "type": "string" - }, - "size_x": { - "type": "number" - }, - "size_y": { - "type": "number" - }, - "size_z": { - "type": "number" - }, - "storey": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - "type_id": { - "anyOf": [ - { - "type": "string" + "type_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] }, - { - "type": "null" - } - ] - }, - "x": { - "type": "number" - }, - "y": { - "type": "number" - } - }, - "required": [ - "building_id", - "capacity", - "description", - "id", - "mapped", - "name", - "rotation", - "short", - "size_x", - "size_y", - "size_z", - "storey", - "type_id", - "x", - "y" - ] - }, - { - "type": "null" - } - ] - }, - "dayDefinition": { - "anyOf": [ - { - "type": "object", - "properties": { - "createdAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "days": { - "anyOf": [ - { - "type": "array", - "items": { - "type": "string" - } + "x": { + "type": "number" }, - { - "type": "null" + "y": { + "type": "number" } + }, + "required": [ + "building_id", + "capacity", + "description", + "id", + "mapped", + "name", + "rotation", + "short", + "size_x", + "size_y", + "size_z", + "storey", + "type_id", + "x", + "y" ] }, - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "short": { - "type": "string" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" + { + "type": "null" } - }, - "required": [ - "createdAt", - "days", - "id", - "name", - "short", - "updatedAt" ] }, - { - "type": "null" - } - ] - }, - "lessons": { - "type": "array", - "items": { - "type": "object", - "properties": { - "classrooms": { - "type": "array", - "items": { + "dayDefinition": { + "anyOf": [ + { "type": "object", "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "days": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, "id": { - "type": "string" + "type": "string", + "format": "uuid" }, "name": { "type": "string" }, "short": { "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" } }, "required": [ + "createdAt", + "days", "id", "name", - "short" + "short", + "updatedAt" ] + }, + { + "type": "null" } - }, - "cohorts": { - "type": "array", - "items": { - "type": "string" - } - }, - "day": { + ] + }, + "lessons": { + "type": "array", + "items": { "type": "object", "properties": { - "createdAt": { + "classrooms": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + } + }, + "cohorts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + } + }, + "day": { + "type": "object", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "days": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "createdAt", + "days", + "id", + "name", + "short", + "updatedAt" + ] + }, + "groups": { + "type": "array", + "items": { + "type": "object", + "properties": { + "divisionTag": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "entireClass": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + } + }, + "required": [ + "divisionTag", + "entireClass", + "id", + "name" + ] + } + }, + "groupsIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "id": { "type": "string", - "format": "date-time", - "x-native-type": "date" + "format": "uuid" + }, + "period": { + "anyOf": [ + { + "type": "object", + "properties": { + "endTime": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "period": { + "type": "number" + }, + "startTime": { + "type": "string" + } + }, + "required": [ + "endTime", + "id", + "period", + "startTime" + ] + }, + { + "type": "null" + } + ] + }, + "periodsPerWeek": { + "type": "number" + }, + "subject": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + }, + { + "type": "null" + } + ] + }, + "teachers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + } }, - "days": { + "termDefinitionId": { "anyOf": [ { - "type": "array", - "items": { - "type": "string" - } + "type": "string", + "format": "uuid" }, { "type": "null" } ] }, - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "short": { - "type": "string" + "weekDefinition": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + }, + "weeks": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "name", + "short", + "weeks" + ] + }, + { + "type": "null" + } + ] }, - "updatedAt": { + "weeksDefinitionId": { "type": "string", - "format": "date-time", - "x-native-type": "date" + "format": "uuid" } }, "required": [ - "createdAt", - "days", + "classrooms", + "cohorts", + "groups", + "groupsIds", "id", - "name", - "short", - "updatedAt" + "period", + "periodsPerWeek", + "subject", + "teachers", + "termDefinitionId", + "weekDefinition", + "weeksDefinitionId" ] - }, - "id": { - "type": "string" - }, - "period": { - "anyOf": [ - { - "type": "object", - "properties": { - "endTime": { - "type": "string" - }, - "id": { - "type": "string" - }, - "period": { - "type": "number" - }, - "startTime": { - "type": "string" - } + } + }, + "movedLesson": { + "type": "object", + "properties": { + "comment": { + "anyOf": [ + { + "type": "string" }, - "required": [ - "endTime", - "id", - "period", - "startTime" - ] - }, - { - "type": "null" - } - ] - }, - "periodsPerWeek": { - "type": "number" - }, - "subject": { - "anyOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "short": { - "type": "string" - } + { + "type": "null" + } + ] + }, + "date": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "room": { + "anyOf": [ + { + "type": "string" }, - "required": [ - "id", - "name", - "short" - ] - }, - { - "type": "null" - } - ] + { + "type": "null" + } + ] + }, + "startingDay": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + }, + "startingPeriod": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + } }, - "teachers": { - "type": "array", - "items": { + "required": [ + "comment", + "date", + "id", + "room", + "startingDay", + "startingPeriod" + ] + }, + "period": { + "anyOf": [ + { "type": "object", "properties": { - "id": { - "type": "string" + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" }, - "name": { + "endTime": { "type": "string" }, - "short": { + "id": { + "type": "string", + "format": "uuid" + }, + "period": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "startTime": { "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" } }, "required": [ + "createdAt", + "endTime", "id", - "name", - "short" + "period", + "startTime", + "updatedAt" ] - } - }, - "termDefinitionId": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "weeksDefinitionId": { - "type": "string" - } - }, - "required": [ - "classrooms", - "cohorts", - "id", - "period", - "periodsPerWeek", - "subject", - "teachers", - "termDefinitionId", - "weeksDefinitionId" - ] - } - }, - "movedLesson": { - "type": "object", - "properties": { - "comment": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "date": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "id": { - "type": "string" - }, - "room": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "startingDay": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "startingPeriod": { - "anyOf": [ - { - "type": "string" }, { "type": "null" @@ -43161,67 +46130,22 @@ } }, "required": [ - "comment", - "date", - "id", - "room", - "startingDay", - "startingPeriod" - ] - }, - "period": { - "anyOf": [ - { - "type": "object", - "properties": { - "createdAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "endTime": { - "type": "string" - }, - "id": { - "type": "string" - }, - "period": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - "startTime": { - "type": "string" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - } - }, - "required": [ - "createdAt", - "endTime", - "id", - "period", - "startTime", - "updatedAt" - ] - }, - { - "type": "null" - } + "classroom", + "dayDefinition", + "lessons", + "movedLesson", + "period" ] } }, - "required": [ - "classroom", - "dayDefinition", - "lessons", - "movedLesson", - "period" - ] - } + "total": { + "type": "number" + } + }, + "required": [ + "items", + "total" + ] } } } @@ -43408,9 +46332,7 @@ } } } - }, - "x-filc_group_name": "MovedLesson", - "x-filc_type_name": "@listof MovedLessonWithRelations @field(.movedLesson, MovedLesson) @field(.classroom, Classroom) @field(.dayDefinition, DayDefinition) @field(.period, Period) @field(.lessons, List)" + } } }, "/timetable/movedLessons/cohort/{cohortId}/relevant": { @@ -43437,63 +46359,469 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "classroom": { - "anyOf": [ - { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "classroom": { + "anyOf": [ + { + "type": "object", + "properties": { + "building_id": { + "type": "string" + }, + "capacity": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mapped": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "rotation": { + "type": "number" + }, + "short": { + "type": "string" + }, + "size_x": { + "type": "number" + }, + "size_y": { + "type": "number" + }, + "size_z": { + "type": "number" + }, + "storey": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "type_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "building_id", + "capacity", + "description", + "id", + "mapped", + "name", + "rotation", + "short", + "size_x", + "size_y", + "size_z", + "storey", + "type_id", + "x", + "y" + ] + }, + { + "type": "null" + } + ] + }, + "dayDefinition": { + "anyOf": [ + { + "type": "object", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "days": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "createdAt", + "days", + "id", + "name", + "short", + "updatedAt" + ] + }, + { + "type": "null" + } + ] + }, + "lessons": { + "type": "array", + "items": { + "type": "object", + "properties": { + "classrooms": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + } + }, + "cohorts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + } + }, + "day": { + "type": "object", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "days": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "createdAt", + "days", + "id", + "name", + "short", + "updatedAt" + ] + }, + "groups": { + "type": "array", + "items": { + "type": "object", + "properties": { + "divisionTag": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "entireClass": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + } + }, + "required": [ + "divisionTag", + "entireClass", + "id", + "name" + ] + } + }, + "groupsIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "id": { + "type": "string", + "format": "uuid" + }, + "period": { + "anyOf": [ + { + "type": "object", + "properties": { + "endTime": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "period": { + "type": "number" + }, + "startTime": { + "type": "string" + } + }, + "required": [ + "endTime", + "id", + "period", + "startTime" + ] + }, + { + "type": "null" + } + ] + }, + "periodsPerWeek": { + "type": "number" + }, + "subject": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + }, + { + "type": "null" + } + ] + }, + "teachers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "short" + ] + } + }, + "termDefinitionId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + }, + "weekDefinition": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + }, + "weeks": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "name", + "short", + "weeks" + ] + }, + { + "type": "null" + } + ] + }, + "weeksDefinitionId": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "classrooms", + "cohorts", + "groups", + "groupsIds", + "id", + "period", + "periodsPerWeek", + "subject", + "teachers", + "termDefinitionId", + "weekDefinition", + "weeksDefinitionId" + ] + } + }, + "movedLesson": { "type": "object", "properties": { - "building_id": { - "type": "string" - }, - "capacity": { + "comment": { "anyOf": [ { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 + "type": "string" }, { "type": "null" } ] }, - "description": { - "type": "string" + "date": { + "type": "string", + "format": "date-time", + "x-native-type": "date" }, "id": { - "type": "string" - }, - "mapped": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "rotation": { - "type": "number" - }, - "short": { - "type": "string" - }, - "size_x": { - "type": "number" - }, - "size_y": { - "type": "number" - }, - "size_z": { - "type": "number" - }, - "storey": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 + "type": "string", + "format": "uuid" }, - "type_id": { + "room": { "anyOf": [ { "type": "string" @@ -43503,322 +46831,77 @@ } ] }, - "x": { - "type": "number" - }, - "y": { - "type": "number" - } - }, - "required": [ - "building_id", - "capacity", - "description", - "id", - "mapped", - "name", - "rotation", - "short", - "size_x", - "size_y", - "size_z", - "storey", - "type_id", - "x", - "y" - ] - }, - { - "type": "null" - } - ] - }, - "dayDefinition": { - "anyOf": [ - { - "type": "object", - "properties": { - "createdAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "days": { + "startingDay": { "anyOf": [ { - "type": "array", - "items": { - "type": "string" - } + "type": "string", + "format": "uuid" }, { "type": "null" } ] }, - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "short": { - "type": "string" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" + "startingPeriod": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] } }, "required": [ - "createdAt", - "days", + "comment", + "date", "id", - "name", - "short", - "updatedAt" + "room", + "startingDay", + "startingPeriod" ] }, - { - "type": "null" - } - ] - }, - "lessons": { - "type": "array", - "items": { - "type": "object", - "properties": { - "classrooms": { - "type": "array", - "items": { + "period": { + "anyOf": [ + { "type": "object", "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" }, - "short": { + "endTime": { "type": "string" - } - }, - "required": [ - "id", - "name", - "short" - ] - } - }, - "cohorts": { - "type": "array", - "items": { - "type": "string" - } - }, - "day": { - "type": "object", - "properties": { - "createdAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "days": { - "anyOf": [ - { - "type": "array", - "items": { - "type": "string" - } - }, - { - "type": "null" - } - ] - }, - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "short": { - "type": "string" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - } - }, - "required": [ - "createdAt", - "days", - "id", - "name", - "short", - "updatedAt" - ] - }, - "id": { - "type": "string" - }, - "period": { - "anyOf": [ - { - "type": "object", - "properties": { - "endTime": { - "type": "string" - }, - "id": { - "type": "string" - }, - "period": { - "type": "number" - }, - "startTime": { - "type": "string" - } - }, - "required": [ - "endTime", - "id", - "period", - "startTime" - ] - }, - { - "type": "null" - } - ] - }, - "periodsPerWeek": { - "type": "number" - }, - "subject": { - "anyOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "short": { - "type": "string" - } }, - "required": [ - "id", - "name", - "short" - ] - }, - { - "type": "null" - } - ] - }, - "teachers": { - "type": "array", - "items": { - "type": "object", - "properties": { "id": { - "type": "string" + "type": "string", + "format": "uuid" }, - "name": { - "type": "string" + "period": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 }, - "short": { + "startTime": { "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" } }, "required": [ + "createdAt", + "endTime", "id", - "name", - "short" + "period", + "startTime", + "updatedAt" ] - } - }, - "termDefinitionId": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "weeksDefinitionId": { - "type": "string" - } - }, - "required": [ - "classrooms", - "cohorts", - "id", - "period", - "periodsPerWeek", - "subject", - "teachers", - "termDefinitionId", - "weeksDefinitionId" - ] - } - }, - "movedLesson": { - "type": "object", - "properties": { - "comment": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "date": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "id": { - "type": "string" - }, - "room": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "startingDay": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "startingPeriod": { - "anyOf": [ - { - "type": "string" }, { "type": "null" @@ -43827,67 +46910,22 @@ } }, "required": [ - "comment", - "date", - "id", - "room", - "startingDay", - "startingPeriod" - ] - }, - "period": { - "anyOf": [ - { - "type": "object", - "properties": { - "createdAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "endTime": { - "type": "string" - }, - "id": { - "type": "string" - }, - "period": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - "startTime": { - "type": "string" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - } - }, - "required": [ - "createdAt", - "endTime", - "id", - "period", - "startTime", - "updatedAt" - ] - }, - { - "type": "null" - } + "classroom", + "dayDefinition", + "lessons", + "movedLesson", + "period" ] } }, - "required": [ - "classroom", - "dayDefinition", - "lessons", - "movedLesson", - "period" - ] - } + "total": { + "type": "number" + } + }, + "required": [ + "items", + "total" + ] } } } @@ -44074,9 +47112,7 @@ } } } - }, - "x-filc_group_name": "MovedLesson", - "x-filc_type_name": "@listof MovedLessonWithRelations @field(.movedLesson, MovedLesson) @field(.classroom, Classroom) @field(.dayDefinition, DayDefinition) @field(.period, Period) @field(.lessons, List)" + } } }, "/users": { @@ -44128,10 +47164,7 @@ "schema": { "type": "object", "properties": { - "total": { - "type": "number" - }, - "users": { + "items": { "type": "array", "items": { "type": "object", @@ -44139,7 +47172,8 @@ "cohortId": { "anyOf": [ { - "type": "string" + "type": "string", + "format": "uuid" }, { "type": "null" @@ -44220,11 +47254,14 @@ "permissions" ] } + }, + "total": { + "type": "number" } }, "required": [ - "total", - "users" + "items", + "total" ] } } @@ -44413,9 +47450,6 @@ } } }, - "x-filc_group_name": "Users", - "x-filc_type_name": "@unit UserListResponse @field(.users, List) @field(.total, Int)", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -44451,7 +47485,8 @@ "cohortId": { "anyOf": [ { - "type": "string" + "type": "string", + "format": "uuid" }, { "type": "null" @@ -44483,7 +47518,8 @@ "cohortId": { "anyOf": [ { - "type": "string" + "type": "string", + "format": "uuid" }, { "type": "null" @@ -44750,9 +47786,6 @@ } } }, - "x-filc_group_name": "Users", - "x-filc_type_name": "@unit User", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -44832,7 +47865,7 @@ "schema": { "type": "object", "properties": { - "logs": { + "items": { "type": "array", "items": { "type": "object", @@ -44952,10 +47985,14 @@ "wifiUserId" ] } + }, + "total": { + "type": "number" } }, "required": [ - "logs" + "items", + "total" ] } } @@ -45144,9 +48181,6 @@ } } }, - "x-filc_group_name": "WiFi", - "x-filc_type_name": "@unit WifiAuthLogListResponse", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -45222,100 +48256,92 @@ "schema": { "type": "object", "properties": { - "device": { - "type": "object", - "properties": { - "adminNotes": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "banned": { - "type": "boolean" + "adminNotes": { + "anyOf": [ + { + "type": "string" }, - "createdAt": { + { + "type": "null" + } + ] + }, + "banned": { + "type": "boolean" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "lastActiveAt": { + "anyOf": [ + { "type": "string", "format": "date-time", "x-native-type": "date" }, - "id": { - "type": "string", - "format": "uuid" - }, - "lastActiveAt": { - "anyOf": [ - { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - { - "type": "null" - } - ] - }, - "macAddress": { - "type": "string", - "pattern": "^(?:[0-9a-fA-F]{12}|(?:[0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2})$" - }, - "nickname": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] + { + "type": "null" + } + ] + }, + "macAddress": { + "type": "string", + "pattern": "^(?:[0-9a-fA-F]{12}|(?:[0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2})$" + }, + "nickname": { + "anyOf": [ + { + "type": "string" }, - "reportedHostname": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] + { + "type": "null" + } + ] + }, + "reportedHostname": { + "anyOf": [ + { + "type": "string" }, - "updatedAt": { + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "wifiUserId": { + "anyOf": [ + { "type": "string", - "format": "date-time", - "x-native-type": "date" + "format": "uuid" }, - "wifiUserId": { - "anyOf": [ - { - "type": "string", - "format": "uuid" - }, - { - "type": "null" - } - ] + { + "type": "null" } - }, - "required": [ - "adminNotes", - "banned", - "createdAt", - "id", - "lastActiveAt", - "macAddress", - "nickname", - "reportedHostname", - "updatedAt", - "wifiUserId" ] } }, "required": [ - "device" + "adminNotes", + "banned", + "createdAt", + "id", + "lastActiveAt", + "macAddress", + "nickname", + "reportedHostname", + "updatedAt", + "wifiUserId" ] } } @@ -45504,9 +48530,6 @@ } } }, - "x-filc_group_name": "WiFi", - "x-filc_type_name": "@unit WifiDeviceResponse", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -45585,7 +48608,7 @@ "schema": { "type": "object", "properties": { - "devices": { + "items": { "type": "array", "items": { "type": "object", @@ -45678,10 +48701,14 @@ "wifiUserId" ] } + }, + "total": { + "type": "number" } }, "required": [ - "devices" + "items", + "total" ] } } @@ -45870,9 +48897,6 @@ } } }, - "x-filc_group_name": "WiFi", - "x-filc_type_name": "@unit WifiDeviceListResponse", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -46101,9 +49125,6 @@ } } }, - "x-filc_group_name": "WiFi", - "x-filc_type_name": "@nodata", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -46185,100 +49206,92 @@ "schema": { "type": "object", "properties": { - "device": { - "type": "object", - "properties": { - "adminNotes": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "banned": { - "type": "boolean" + "adminNotes": { + "anyOf": [ + { + "type": "string" }, - "createdAt": { + { + "type": "null" + } + ] + }, + "banned": { + "type": "boolean" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "lastActiveAt": { + "anyOf": [ + { "type": "string", "format": "date-time", "x-native-type": "date" }, - "id": { - "type": "string", - "format": "uuid" - }, - "lastActiveAt": { - "anyOf": [ - { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - { - "type": "null" - } - ] - }, - "macAddress": { - "type": "string", - "pattern": "^(?:[0-9a-fA-F]{12}|(?:[0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2})$" - }, - "nickname": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] + { + "type": "null" + } + ] + }, + "macAddress": { + "type": "string", + "pattern": "^(?:[0-9a-fA-F]{12}|(?:[0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2})$" + }, + "nickname": { + "anyOf": [ + { + "type": "string" }, - "reportedHostname": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] + { + "type": "null" + } + ] + }, + "reportedHostname": { + "anyOf": [ + { + "type": "string" }, - "updatedAt": { + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "wifiUserId": { + "anyOf": [ + { "type": "string", - "format": "date-time", - "x-native-type": "date" + "format": "uuid" }, - "wifiUserId": { - "anyOf": [ - { - "type": "string", - "format": "uuid" - }, - { - "type": "null" - } - ] + { + "type": "null" } - }, - "required": [ - "adminNotes", - "banned", - "createdAt", - "id", - "lastActiveAt", - "macAddress", - "nickname", - "reportedHostname", - "updatedAt", - "wifiUserId" ] } }, "required": [ - "device" + "adminNotes", + "banned", + "createdAt", + "id", + "lastActiveAt", + "macAddress", + "nickname", + "reportedHostname", + "updatedAt", + "wifiUserId" ] } } @@ -46467,9 +49480,6 @@ } } }, - "x-filc_group_name": "WiFi", - "x-filc_type_name": "@unit WifiDeviceResponse", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -46525,53 +49535,45 @@ "schema": { "type": "object", "properties": { - "nas": { - "type": "object", - "properties": { - "comment": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "createdAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "id": { - "type": "string", - "format": "uuid" - }, - "ipAddress": { + "comment": { + "anyOf": [ + { "type": "string" }, - "macAddress": { - "type": "string", - "pattern": "^(?:[0-9a-fA-F]{12}|(?:[0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2})$" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" + { + "type": "null" } - }, - "required": [ - "comment", - "createdAt", - "id", - "ipAddress", - "macAddress", - "updatedAt" ] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "ipAddress": { + "type": "string" + }, + "macAddress": { + "type": "string", + "pattern": "^(?:[0-9a-fA-F]{12}|(?:[0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2})$" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" } }, "required": [ - "nas" + "comment", + "createdAt", + "id", + "ipAddress", + "macAddress", + "updatedAt" ] } } @@ -46760,9 +49762,6 @@ } } }, - "x-filc_group_name": "WiFi", - "x-filc_type_name": "@unit WifiNasResponse", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -46783,7 +49782,7 @@ "schema": { "type": "object", "properties": { - "nas": { + "items": { "type": "array", "items": { "type": "object", @@ -46829,10 +49828,14 @@ "updatedAt" ] } + }, + "total": { + "type": "number" } }, "required": [ - "nas" + "items", + "total" ] } } @@ -47021,9 +50024,6 @@ } } }, - "x-filc_group_name": "WiFi", - "x-filc_type_name": "@unit WifiNasListResponse", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -47252,9 +50252,6 @@ } } }, - "x-filc_group_name": "WiFi", - "x-filc_type_name": "@nodata", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -47315,53 +50312,45 @@ "schema": { "type": "object", "properties": { - "nas": { - "type": "object", - "properties": { - "comment": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "createdAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "id": { - "type": "string", - "format": "uuid" - }, - "ipAddress": { + "comment": { + "anyOf": [ + { "type": "string" }, - "macAddress": { - "type": "string", - "pattern": "^(?:[0-9a-fA-F]{12}|(?:[0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2})$" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" + { + "type": "null" } - }, - "required": [ - "comment", - "createdAt", - "id", - "ipAddress", - "macAddress", - "updatedAt" ] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "ipAddress": { + "type": "string" + }, + "macAddress": { + "type": "string", + "pattern": "^(?:[0-9a-fA-F]{12}|(?:[0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2})$" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" } }, "required": [ - "nas" + "comment", + "createdAt", + "id", + "ipAddress", + "macAddress", + "updatedAt" ] } } @@ -47550,9 +50539,6 @@ } } }, - "x-filc_group_name": "WiFi", - "x-filc_type_name": "@unit WifiNasResponse", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -47815,9 +50801,7 @@ } } } - }, - "x-filc_group_name": "WiFi", - "x-filc_type_name": "@unit WifiRadiusAuthorizeResponse" + } } }, "/wifi/role-speed-profiles": { @@ -47865,30 +50849,22 @@ "schema": { "type": "object", "properties": { - "mapping": { - "type": "object", - "properties": { - "priority": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - "roleName": { - "type": "string" - }, - "speedProfileId": { - "type": "string" - } - }, - "required": [ - "priority", - "roleName", - "speedProfileId" - ] + "priority": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "roleName": { + "type": "string" + }, + "speedProfileId": { + "type": "string" } }, "required": [ - "mapping" + "priority", + "roleName", + "speedProfileId" ] } } @@ -48077,9 +51053,6 @@ } } }, - "x-filc_group_name": "WiFi", - "x-filc_type_name": "@unit WifiRoleSpeedProfileResponse", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -48100,7 +51073,7 @@ "schema": { "type": "object", "properties": { - "roleSpeedProfiles": { + "items": { "type": "array", "items": { "type": "object", @@ -48123,10 +51096,14 @@ "speedProfileId" ] } + }, + "total": { + "type": "number" } }, "required": [ - "roleSpeedProfiles" + "items", + "total" ] } } @@ -48315,9 +51292,6 @@ } } }, - "x-filc_group_name": "WiFi", - "x-filc_type_name": "@unit WifiRoleSpeedProfileListResponse", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -48545,9 +51519,6 @@ } } }, - "x-filc_group_name": "WiFi", - "x-filc_type_name": "@nodata", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -48605,30 +51576,22 @@ "schema": { "type": "object", "properties": { - "mapping": { - "type": "object", - "properties": { - "priority": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - "roleName": { - "type": "string" - }, - "speedProfileId": { - "type": "string" - } - }, - "required": [ - "priority", - "roleName", - "speedProfileId" - ] + "priority": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "roleName": { + "type": "string" + }, + "speedProfileId": { + "type": "string" } }, "required": [ - "mapping" + "priority", + "roleName", + "speedProfileId" ] } } @@ -48817,9 +51780,6 @@ } } }, - "x-filc_group_name": "WiFi", - "x-filc_type_name": "@unit WifiRoleSpeedProfileResponse", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -49029,9 +51989,6 @@ } } }, - "x-filc_group_name": "WiFi", - "x-filc_type_name": "@nodata", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -49268,9 +52225,6 @@ } } }, - "x-filc_group_name": "WiFi", - "x-filc_type_name": "@nodata", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -49313,148 +52267,140 @@ "schema": { "type": "object", "properties": { - "wifi": { - "type": "object", - "properties": { - "banned": { - "type": "boolean" - }, - "devices": { - "type": "array", - "items": { - "type": "object", - "properties": { - "banned": { - "type": "boolean" - }, - "id": { + "banned": { + "type": "boolean" + }, + "devices": { + "type": "array", + "items": { + "type": "object", + "properties": { + "banned": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "lastActiveAt": { + "anyOf": [ + { "type": "string", - "format": "uuid" - }, - "lastActiveAt": { - "anyOf": [ - { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - { - "type": "null" - } - ] + "format": "date-time", + "x-native-type": "date" }, - "macAddress": { + { + "type": "null" + } + ] + }, + "macAddress": { + "type": "string" + }, + "nickname": { + "anyOf": [ + { "type": "string" }, - "nickname": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] + { + "type": "null" + } + ] + }, + "reportedHostname": { + "anyOf": [ + { + "type": "string" }, - "reportedHostname": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] + { + "type": "null" } - }, - "required": [ - "banned", - "id", - "lastActiveAt", - "macAddress", - "nickname", - "reportedHostname" ] } }, - "speedLimit": { - "type": "object", - "properties": { - "downloadSpeedMbps": { - "anyOf": [ - { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - { - "type": "null" - } - ] - }, - "roleName": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] + "required": [ + "banned", + "id", + "lastActiveAt", + "macAddress", + "nickname", + "reportedHostname" + ] + } + }, + "speedLimit": { + "type": "object", + "properties": { + "downloadSpeedMbps": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 }, - "source": { - "enum": [ - "override", - "role", - "wlan_default" - ], + { + "type": "null" + } + ] + }, + "roleName": { + "anyOf": [ + { "type": "string" }, - "speedProfileId": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "uploadSpeedMbps": { - "anyOf": [ - { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - { - "type": "null" - } - ] + { + "type": "null" } - }, - "required": [ - "downloadSpeedMbps", - "roleName", - "source", - "speedProfileId", - "uploadSpeedMbps" ] }, - "username": { + "source": { + "enum": [ + "override", + "role", + "wlan_default" + ], "type": "string" + }, + "speedProfileId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "uploadSpeedMbps": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] } }, "required": [ - "banned", - "devices", - "speedLimit", - "username" + "downloadSpeedMbps", + "roleName", + "source", + "speedProfileId", + "uploadSpeedMbps" ] + }, + "username": { + "type": "string" } }, "required": [ - "wifi" + "banned", + "devices", + "speedLimit", + "username" ] } } @@ -49643,9 +52589,6 @@ } } }, - "x-filc_group_name": "WiFi", - "x-filc_type_name": "@unit WifiSelfCreateResponse @field(.wifi, WifiSelf)", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -49666,148 +52609,140 @@ "schema": { "type": "object", "properties": { - "wifi": { - "type": "object", - "properties": { - "banned": { - "type": "boolean" - }, - "devices": { - "type": "array", - "items": { - "type": "object", - "properties": { - "banned": { - "type": "boolean" - }, - "id": { + "banned": { + "type": "boolean" + }, + "devices": { + "type": "array", + "items": { + "type": "object", + "properties": { + "banned": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "lastActiveAt": { + "anyOf": [ + { "type": "string", - "format": "uuid" - }, - "lastActiveAt": { - "anyOf": [ - { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - { - "type": "null" - } - ] + "format": "date-time", + "x-native-type": "date" }, - "macAddress": { + { + "type": "null" + } + ] + }, + "macAddress": { + "type": "string" + }, + "nickname": { + "anyOf": [ + { "type": "string" }, - "nickname": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] + { + "type": "null" + } + ] + }, + "reportedHostname": { + "anyOf": [ + { + "type": "string" }, - "reportedHostname": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] + { + "type": "null" } - }, - "required": [ - "banned", - "id", - "lastActiveAt", - "macAddress", - "nickname", - "reportedHostname" ] } }, - "speedLimit": { - "type": "object", - "properties": { - "downloadSpeedMbps": { - "anyOf": [ - { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - { - "type": "null" - } - ] - }, - "roleName": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] + "required": [ + "banned", + "id", + "lastActiveAt", + "macAddress", + "nickname", + "reportedHostname" + ] + } + }, + "speedLimit": { + "type": "object", + "properties": { + "downloadSpeedMbps": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 }, - "source": { - "enum": [ - "override", - "role", - "wlan_default" - ], + { + "type": "null" + } + ] + }, + "roleName": { + "anyOf": [ + { "type": "string" }, - "speedProfileId": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "uploadSpeedMbps": { - "anyOf": [ - { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - { - "type": "null" - } - ] + { + "type": "null" } - }, - "required": [ - "downloadSpeedMbps", - "roleName", - "source", - "speedProfileId", - "uploadSpeedMbps" ] }, - "username": { + "source": { + "enum": [ + "override", + "role", + "wlan_default" + ], "type": "string" + }, + "speedProfileId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "uploadSpeedMbps": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] } }, "required": [ - "banned", - "devices", - "speedLimit", - "username" + "downloadSpeedMbps", + "roleName", + "source", + "speedProfileId", + "uploadSpeedMbps" ] + }, + "username": { + "type": "string" } }, "required": [ - "wifi" + "banned", + "devices", + "speedLimit", + "username" ] } } @@ -49996,9 +52931,6 @@ } } }, - "x-filc_group_name": "WiFi", - "x-filc_type_name": "@unit WifiSelfResponse @field(.wifi, WifiSelf)", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -50058,64 +52990,56 @@ "schema": { "type": "object", "properties": { - "device": { - "type": "object", - "properties": { - "banned": { - "type": "boolean" - }, - "id": { + "banned": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "lastActiveAt": { + "anyOf": [ + { "type": "string", - "format": "uuid" - }, - "lastActiveAt": { - "anyOf": [ - { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - { - "type": "null" - } - ] + "format": "date-time", + "x-native-type": "date" }, - "macAddress": { + { + "type": "null" + } + ] + }, + "macAddress": { + "type": "string" + }, + "nickname": { + "anyOf": [ + { "type": "string" }, - "nickname": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] + { + "type": "null" + } + ] + }, + "reportedHostname": { + "anyOf": [ + { + "type": "string" }, - "reportedHostname": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] + { + "type": "null" } - }, - "required": [ - "banned", - "id", - "lastActiveAt", - "macAddress", - "nickname", - "reportedHostname" ] } }, "required": [ - "device" + "banned", + "id", + "lastActiveAt", + "macAddress", + "nickname", + "reportedHostname" ] } } @@ -50304,9 +53228,6 @@ } } }, - "x-filc_group_name": "WiFi", - "x-filc_type_name": "@unit WifiSelfDeviceResponse @field(.device, WifiSelfDevice)", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -50360,54 +53281,46 @@ "schema": { "type": "object", "properties": { - "profile": { - "type": "object", - "properties": { - "downloadSpeedMbps": { - "anyOf": [ - { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - { - "type": "null" - } - ] - }, - "id": { - "type": "string" - }, - "isWlanDefault": { - "type": "boolean" + "downloadSpeedMbps": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 }, - "name": { - "type": "string" + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "isWlanDefault": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "uploadSpeedMbps": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 }, - "uploadSpeedMbps": { - "anyOf": [ - { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - { - "type": "null" - } - ] + { + "type": "null" } - }, - "required": [ - "downloadSpeedMbps", - "id", - "isWlanDefault", - "name", - "uploadSpeedMbps" ] } }, "required": [ - "profile" + "downloadSpeedMbps", + "id", + "isWlanDefault", + "name", + "uploadSpeedMbps" ] } } @@ -50596,9 +53509,6 @@ } } }, - "x-filc_group_name": "WiFi", - "x-filc_type_name": "@unit WifiSpeedProfileResponse", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -50619,7 +53529,7 @@ "schema": { "type": "object", "properties": { - "speedProfiles": { + "items": { "type": "array", "items": { "type": "object", @@ -50666,10 +53576,14 @@ "uploadSpeedMbps" ] } + }, + "total": { + "type": "number" } }, "required": [ - "speedProfiles" + "items", + "total" ] } } @@ -50858,9 +53772,6 @@ } } }, - "x-filc_group_name": "WiFi", - "x-filc_type_name": "@unit WifiSpeedProfileListResponse", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -51088,9 +53999,6 @@ } } }, - "x-filc_group_name": "WiFi", - "x-filc_type_name": "@nodata", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -51150,54 +54058,46 @@ "schema": { "type": "object", "properties": { - "profile": { - "type": "object", - "properties": { - "downloadSpeedMbps": { - "anyOf": [ - { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - { - "type": "null" - } - ] - }, - "id": { - "type": "string" - }, - "isWlanDefault": { - "type": "boolean" + "downloadSpeedMbps": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 }, - "name": { - "type": "string" + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "isWlanDefault": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "uploadSpeedMbps": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 }, - "uploadSpeedMbps": { - "anyOf": [ - { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - { - "type": "null" - } - ] + { + "type": "null" } - }, - "required": [ - "downloadSpeedMbps", - "id", - "isWlanDefault", - "name", - "uploadSpeedMbps" ] } }, "required": [ - "profile" + "downloadSpeedMbps", + "id", + "isWlanDefault", + "name", + "uploadSpeedMbps" ] } } @@ -51386,9 +54286,6 @@ } } }, - "x-filc_group_name": "WiFi", - "x-filc_type_name": "@unit WifiSpeedProfileResponse", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -51411,53 +54308,61 @@ "schema": { "type": "object", "properties": { - "activeDevices": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - "authSeries": { - "type": "array", - "items": { - "type": "object", - "properties": { - "accepted": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - "date": { - "type": "string" - }, - "rejected": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 + "stats": { + "type": "object", + "properties": { + "activeDevices": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "authSeries": { + "type": "array", + "items": { + "type": "object", + "properties": { + "accepted": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "date": { + "type": "string" + }, + "rejected": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "accepted", + "date", + "rejected" + ] } }, - "required": [ - "accepted", - "date", - "rejected" - ] - } - }, - "totalDevices": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - "totalUsers": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 + "totalDevices": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "totalUsers": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "activeDevices", + "authSeries", + "totalDevices", + "totalUsers" + ] } }, "required": [ - "activeDevices", - "authSeries", - "totalDevices", - "totalUsers" + "stats" ] } } @@ -51646,9 +54551,6 @@ } } }, - "x-filc_group_name": "WiFi", - "x-filc_type_name": "@unit WifiStatsResponse @field(.stats, WifiStats)", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -51875,9 +54777,7 @@ } } } - }, - "x-filc_group_name": "WiFi", - "x-filc_type_name": "@unit WifiStatusResponse" + } } }, "/wifi/users": { @@ -51958,126 +54858,118 @@ "schema": { "type": "object", "properties": { - "user": { - "type": "object", - "properties": { - "allowedMacAddresses": { - "anyOf": [ - { - "type": "array", - "items": { - "type": "string", - "pattern": "^(?:[0-9a-fA-F]{12}|(?:[0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2})$" - } - }, - { - "type": "null" - } - ] - }, - "banned": { - "type": "boolean" - }, - "comment": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "createdAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "createdBy": { - "anyOf": [ - { - "type": "string", - "format": "uuid" - }, - { - "type": "null" - } - ] + "allowedMacAddresses": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string", + "pattern": "^(?:[0-9a-fA-F]{12}|(?:[0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2})$" + } }, - "creatorName": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] + { + "type": "null" + } + ] + }, + "banned": { + "type": "boolean" + }, + "comment": { + "anyOf": [ + { + "type": "string" }, - "id": { + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "createdBy": { + "anyOf": [ + { "type": "string", "format": "uuid" }, - "lastActiveAt": { - "anyOf": [ - { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - { - "type": "null" - } - ] - }, - "speedProfileId": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] + { + "type": "null" + } + ] + }, + "creatorName": { + "anyOf": [ + { + "type": "string" }, - "updatedAt": { + { + "type": "null" + } + ] + }, + "id": { + "type": "string", + "format": "uuid" + }, + "lastActiveAt": { + "anyOf": [ + { "type": "string", "format": "date-time", "x-native-type": "date" }, - "userId": { - "anyOf": [ - { - "type": "string", - "format": "uuid" - }, - { - "type": "null" - } - ] - }, - "username": { + { + "type": "null" + } + ] + }, + "speedProfileId": { + "anyOf": [ + { "type": "string" + }, + { + "type": "null" } - }, - "required": [ - "allowedMacAddresses", - "banned", - "comment", - "createdAt", - "createdBy", - "creatorName", - "id", - "lastActiveAt", - "speedProfileId", - "updatedAt", - "userId", - "username" ] + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "userId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + }, + "username": { + "type": "string" } }, "required": [ - "user" + "allowedMacAddresses", + "banned", + "comment", + "createdAt", + "createdBy", + "creatorName", + "id", + "lastActiveAt", + "speedProfileId", + "updatedAt", + "userId", + "username" ] } } @@ -52266,9 +55158,6 @@ } } }, - "x-filc_group_name": "WiFi", - "x-filc_type_name": "@unit WifiUserResponse", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -52324,7 +55213,7 @@ "schema": { "type": "object", "properties": { - "users": { + "items": { "type": "array", "items": { "type": "object", @@ -52443,10 +55332,14 @@ "username" ] } + }, + "total": { + "type": "number" } }, "required": [ - "users" + "items", + "total" ] } } @@ -52635,9 +55528,6 @@ } } }, - "x-filc_group_name": "WiFi", - "x-filc_type_name": "@unit WifiUserListResponse", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -52866,9 +55756,6 @@ } } }, - "x-filc_group_name": "WiFi", - "x-filc_type_name": "@nodata", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] @@ -52969,126 +55856,118 @@ "schema": { "type": "object", "properties": { - "user": { - "type": "object", - "properties": { - "allowedMacAddresses": { - "anyOf": [ - { - "type": "array", - "items": { - "type": "string", - "pattern": "^(?:[0-9a-fA-F]{12}|(?:[0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2})$" - } - }, - { - "type": "null" - } - ] - }, - "banned": { - "type": "boolean" - }, - "comment": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "createdAt": { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - "createdBy": { - "anyOf": [ - { - "type": "string", - "format": "uuid" - }, - { - "type": "null" - } - ] + "allowedMacAddresses": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string", + "pattern": "^(?:[0-9a-fA-F]{12}|(?:[0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2})$" + } }, - "creatorName": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] + { + "type": "null" + } + ] + }, + "banned": { + "type": "boolean" + }, + "comment": { + "anyOf": [ + { + "type": "string" }, - "id": { + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "createdBy": { + "anyOf": [ + { "type": "string", "format": "uuid" }, - "lastActiveAt": { - "anyOf": [ - { - "type": "string", - "format": "date-time", - "x-native-type": "date" - }, - { - "type": "null" - } - ] - }, - "speedProfileId": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] + { + "type": "null" + } + ] + }, + "creatorName": { + "anyOf": [ + { + "type": "string" }, - "updatedAt": { + { + "type": "null" + } + ] + }, + "id": { + "type": "string", + "format": "uuid" + }, + "lastActiveAt": { + "anyOf": [ + { "type": "string", "format": "date-time", "x-native-type": "date" }, - "userId": { - "anyOf": [ - { - "type": "string", - "format": "uuid" - }, - { - "type": "null" - } - ] - }, - "username": { + { + "type": "null" + } + ] + }, + "speedProfileId": { + "anyOf": [ + { "type": "string" + }, + { + "type": "null" } - }, - "required": [ - "allowedMacAddresses", - "banned", - "comment", - "createdAt", - "createdBy", - "creatorName", - "id", - "lastActiveAt", - "speedProfileId", - "updatedAt", - "userId", - "username" ] + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "userId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + }, + "username": { + "type": "string" } }, "required": [ - "user" + "allowedMacAddresses", + "banned", + "comment", + "createdAt", + "createdBy", + "creatorName", + "id", + "lastActiveAt", + "speedProfileId", + "updatedAt", + "userId", + "username" ] } } @@ -53277,9 +56156,6 @@ } } }, - "x-filc_group_name": "WiFi", - "x-filc_type_name": "@unit WifiUserResponse", - "x-filc_auth": "true", "security": [ { "sessionAuth": [] diff --git a/apps/chronos/package.json b/apps/chronos/package.json index 25aaed1a..b4bb3b7c 100644 --- a/apps/chronos/package.json +++ b/apps/chronos/package.json @@ -9,6 +9,7 @@ "db:seed": "bun run ./src/database/scripts/seed.ts", "db:import-navigator": "bun --bun src/database/scripts/import-navigator.ts", "db:reset": "bun run ./src/database/scripts/reset.ts", + "openapi:check": "bun run scripts/check-openapi.ts", "openapi:generate": "bun run scripts/generate-openapi.ts", "build": "bun run build.ts", "typecheck": "tsc" diff --git a/apps/chronos/scripts/check-openapi.ts b/apps/chronos/scripts/check-openapi.ts new file mode 100644 index 00000000..9086be0a --- /dev/null +++ b/apps/chronos/scripts/check-openapi.ts @@ -0,0 +1,171 @@ +import { readFile } from 'node:fs/promises'; +import process from 'node:process'; +import { + buildOpenApiDocument, + serializeOpenApiDocument, +} from './openapi-document'; + +/** + * Guards the public contract. Two failures, both of which have bitten this repo. + * + * 1. **Stale document** — the committed `openapi/chronos-openapi.json` no longer + * matches what the contract generates. External clients consume the committed + * file, so a contract edit that skips `bun run openapi:generate` ships them a + * lie. + * + * 2. **A delete returning a row** — `AGENTS.md` states deletes answer `{ id }` + * (or `{ ok: true }` where there is no id). A delete that answers the entity + * instead gives every generated client the wrong model. + * + * Run with `bun run openapi:check`. + */ + +type JsonSchema = { + $ref?: string; + allOf?: JsonSchema[]; + anyOf?: JsonSchema[]; + items?: JsonSchema; + oneOf?: JsonSchema[]; + properties?: Record; + type?: string; +}; + +type Operation = { + operationId?: string; + responses?: Record< + string, + { content?: Record } + >; +}; + +type Document = { + components?: { schemas?: Record }; + paths?: Record>; +}; + +const DOCUMENT_PATH = 'openapi/chronos-openapi.json'; + +/** + * Deletes answer `{ id }` (or `{ ok: true }`). This one deliberately returns the + * refreshed parent row: it deletes an announcement's *image*, and the admin + * screen repaints the row from the response. + */ +const DELETE_EXCEPTIONS: Record = { + deleteNewsAnnouncementsByIdImage: + 'deletes a sub-resource; returns the refreshed announcement', +}; + +const spec = (await buildOpenApiDocument()) as unknown as Document; +const expected = serializeOpenApiDocument(spec); +const out = (line: string) => process.stdout.write(`${line}\n`); +const fail = (line: string) => process.stderr.write(`${line}\n`); + +let failed = false; + +// 1. the committed document must be what the contract generates +let committed: string | null; +try { + committed = await readFile(DOCUMENT_PATH, 'utf8'); +} catch { + committed = null; +} + +if (committed === null) { + fail(`✗ ${DOCUMENT_PATH} is missing — run \`bun run openapi:generate\``); + failed = true; +} else if (committed === expected) { + out(`✓ ${DOCUMENT_PATH} matches the contract`); +} else { + fail( + `✗ ${DOCUMENT_PATH} is stale — run \`bun run openapi:generate\` and commit it.` + ); + fail( + ' A change here is client-visible: the mobile client generates from this document.' + ); + failed = true; +} + +// 2. deletes answer `{ id }`, not a row +const deref = (schema: JsonSchema | undefined): JsonSchema | undefined => { + if (!schema?.$ref) { + return schema; + } + const name = schema.$ref.split('/').pop() ?? ''; + return spec.components?.schemas?.[name] ?? schema; +}; + +/** Every property name a response can carry, across union branches. */ +const collectProperties = ( + schema: JsonSchema | undefined, + found: Set = new Set() +): Set => { + const node = deref(schema); + if (!node) { + return found; + } + + const alternatives = node.anyOf ?? node.oneOf ?? node.allOf; + if (alternatives?.length) { + for (const alternative of alternatives) { + collectProperties(alternative, found); + } + return found; + } + + for (const name of Object.keys(node.properties ?? {})) { + found.add(name); + } + return found; +}; + +const convention: string[] = []; +const known: string[] = []; + +for (const methods of Object.values(spec.paths ?? {})) { + for (const [method, operation] of Object.entries(methods)) { + if (method !== 'delete' || !operation.operationId) { + continue; + } + + // A create answers 201; a delete answers 200. + const success = + operation.responses?.['200'] ?? operation.responses?.['201']; + const body = collectProperties( + success?.content?.['application/json']?.schema + ); + const isDeleteShape = + body.size === 0 || + (body.size === 1 && (body.has('id') || body.has('ok'))); + if (isDeleteShape) { + continue; + } + + const line = `${operation.operationId}: DELETE answers {${[...body].sort().join(', ')}}`; + if (DELETE_EXCEPTIONS[operation.operationId]) { + known.push(`${line}\n ${DELETE_EXCEPTIONS[operation.operationId]}`); + } else { + convention.push( + `${line} — deletes answer { id } (or { ok: true }); a client reading .id from this breaks` + ); + } + } +} + +if (known.length > 0) { + out(`! ${known.length} known divergence(s):`); + for (const line of known) { + out(` ${line}`); + } +} + +if (convention.length > 0) { + fail(`✗ ${convention.length} delete(s) break a documented convention:`); + for (const line of convention) { + fail(` ${line}`); + } + failed = true; +} else { + out('✓ every delete answers { id } (or { ok: true })'); +} + +process.exit(failed ? 1 : 0); diff --git a/apps/chronos/scripts/generate-openapi.ts b/apps/chronos/scripts/generate-openapi.ts index 574263b4..8a8fd005 100644 --- a/apps/chronos/scripts/generate-openapi.ts +++ b/apps/chronos/scripts/generate-openapi.ts @@ -1,10 +1,10 @@ import { mkdir, writeFile } from 'node:fs/promises'; -import { appContract } from '@filcdev/api/contract'; import { getLogger } from '@logtape/logtape'; -import { OpenAPIGenerator } from '@orpc/openapi'; -import { ZodToJsonSchemaConverter } from '@orpc/zod/zod4'; import { configureLogger } from '#utils/logger'; -import { openApiSpecOptions } from '#utils/openapi-spec'; +import { + buildOpenApiDocument, + serializeOpenApiDocument, +} from './openapi-document'; /** * Writes `openapi/chronos-openapi.json` — the document `/api/doc/openapi.json` @@ -13,20 +13,14 @@ import { openApiSpecOptions } from '#utils/openapi-spec'; * this file as generated. * * Run with `bun run openapi:generate` after any contract change, then refresh a - * consumer's copy (e.g. mergen's `openapi/filc-openapi.json`). + * consumer's copy (e.g. mergen's `openapi/filc-openapi.json`). `bun run + * openapi:check` fails when this file is stale instead of writing it. */ -const generator = new OpenAPIGenerator({ - schemaConverters: [new ZodToJsonSchemaConverter()], -}); - -const spec = await generator.generate(appContract, openApiSpecOptions); +const spec = await buildOpenApiDocument(); await mkdir('openapi', { recursive: true }); -await writeFile( - 'openapi/chronos-openapi.json', - `${JSON.stringify(spec, null, 2)}\n` -); +await writeFile('openapi/chronos-openapi.json', serializeOpenApiDocument(spec)); await configureLogger('chronos'); getLogger(['chronos', 'openapi']).info( diff --git a/apps/chronos/scripts/openapi-document.ts b/apps/chronos/scripts/openapi-document.ts new file mode 100644 index 00000000..a1d2a20f --- /dev/null +++ b/apps/chronos/scripts/openapi-document.ts @@ -0,0 +1,22 @@ +import { appContract } from '@filcdev/api/contract'; +import { OpenAPIGenerator } from '@orpc/openapi'; +import { ZodToJsonSchemaConverter } from '@orpc/zod/zod4'; +import { openApiSpecOptions } from '#utils/openapi-spec'; + +/** + * Builds the Chronos OpenAPI document in memory. Shared by + * `generate-openapi.ts` (which writes it) and `check-openapi.ts` (which checks + * the committed copy), so the two can never drift from each other. + * + * The document is exactly what `/api/doc/openapi.json` serves: the same + * contract and the same options (`#utils/openapi-spec`), nothing + * post-processed. + */ +export const buildOpenApiDocument = () => + new OpenAPIGenerator({ + schemaConverters: [new ZodToJsonSchemaConverter()], + }).generate(appContract, openApiSpecOptions); + +/** The exact bytes that belong in `openapi/chronos-openapi.json`. */ +export const serializeOpenApiDocument = (spec: unknown): string => + `${JSON.stringify(spec, null, 2)}\n`; diff --git a/apps/chronos/src/database/migrations/0025_mean_wildside.sql b/apps/chronos/src/database/migrations/0025_mean_wildside.sql new file mode 100644 index 00000000..37811e0a --- /dev/null +++ b/apps/chronos/src/database/migrations/0025_mean_wildside.sql @@ -0,0 +1 @@ +CREATE UNIQUE INDEX "fcm_token_user_id_token_unique" ON "fcm_token" USING btree ("user_id","token"); \ No newline at end of file diff --git a/apps/chronos/src/database/migrations/meta/0025_snapshot.json b/apps/chronos/src/database/migrations/meta/0025_snapshot.json new file mode 100644 index 00000000..3ba7cdbe --- /dev/null +++ b/apps/chronos/src/database/migrations/meta/0025_snapshot.json @@ -0,0 +1,4560 @@ +{ + "id": "cb44b4a4-41bd-4fa2-b150-cc7e0b374252", + "prevId": "eba14589-f75e-4524-8be0-4005795c69e6", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.apikey": { + "name": "apikey", + "schema": "", + "columns": { + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "apikey_config_id_idx": { + "name": "apikey_config_id_idx", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikey_key_idx": { + "name": "apikey_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikey_reference_id_idx": { + "name": "apikey_reference_id_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "cohort_id": { + "name": "cohort_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "nickname": { + "name": "nickname", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "roles": { + "name": "roles", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{\"user\"}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.role": { + "name": "role", + "schema": "", + "columns": { + "can": { + "name": "can", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "role_name_unique": { + "name": "role_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bug_report": { + "name": "bug_report", + "schema": "", + "columns": { + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "page": { + "name": "page", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reporter_email": { + "name": "reporter_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reporter_id": { + "name": "reporter_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "bug_report_status_idx": { + "name": "bug_report_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bug_report_reporter_id_user_id_fk": { + "name": "bug_report_reporter_id_user_id_fk", + "tableFrom": "bug_report", + "tableTo": "user", + "columnsFrom": [ + "reporter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "button_pressed": { + "name": "button_pressed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "card_data": { + "name": "card_data", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "card_id": { + "name": "card_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "device_id": { + "name": "device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "audit_log_card_id_card_id_fk": { + "name": "audit_log_card_id_card_id_fk", + "tableFrom": "audit_log", + "tableTo": "card", + "columnsFrom": [ + "card_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_device_id_device_id_fk": { + "name": "audit_log_device_id_device_id_fk", + "tableFrom": "audit_log", + "tableTo": "device", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_log_user_id_user_id_fk": { + "name": "audit_log_user_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.card": { + "name": "card", + "schema": "", + "columns": { + "card_uid": { + "name": "card_uid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "frozen": { + "name": "frozen", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "card_user_id_user_id_fk": { + "name": "card_user_id_user_id_fk", + "tableFrom": "card", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "card_card_uid_unique": { + "name": "card_card_uid_unique", + "nullsNotDistinct": false, + "columns": [ + "card_uid" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.card_device": { + "name": "card_device", + "schema": "", + "columns": { + "card_id": { + "name": "card_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "card_device_card_id_idx": { + "name": "card_device_card_id_idx", + "columns": [ + { + "expression": "card_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "card_device_device_id_idx": { + "name": "card_device_device_id_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "card_device_card_id_card_id_fk": { + "name": "card_device_card_id_card_id_fk", + "tableFrom": "card_device", + "tableTo": "card", + "columnsFrom": [ + "card_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "card_device_device_id_device_id_fk": { + "name": "card_device_device_id_device_id_fk", + "tableFrom": "card_device", + "tableTo": "device", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "card_device_card_id_device_id_pk": { + "name": "card_device_card_id_device_id_pk", + "columns": [ + "card_id", + "device_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device": { + "name": "device", + "schema": "", + "columns": { + "api_token": { + "name": "api_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "last_reset_reason": { + "name": "last_reset_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "device_api_token_unique": { + "name": "device_api_token_unique", + "nullsNotDistinct": false, + "columns": [ + "api_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_health": { + "name": "device_health", + "schema": "", + "columns": { + "device_id": { + "name": "device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "device_meta": { + "name": "device_meta", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "device_health_device_id_device_id_fk": { + "name": "device_health_device_id_device_id_fk", + "tableFrom": "device_health", + "tableTo": "device", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiosk": { + "name": "kiosk", + "schema": "", + "columns": { + "app_version": { + "name": "app_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_seen_ip": { + "name": "last_seen_ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "machine_id": { + "name": "machine_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "kiosk_machine_id_unique": { + "name": "kiosk_machine_id_unique", + "nullsNotDistinct": false, + "columns": [ + "machine_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.navigator_corridor": { + "name": "navigator_corridor", + "schema": "", + "columns": { + "barrier_free": { + "name": "barrier_free", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "building_id": { + "name": "building_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "is_outdoor": { + "name": "is_outdoor", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storey": { + "name": "storey", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "x1": { + "name": "x1", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "x2": { + "name": "x2", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "y1": { + "name": "y1", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "y2": { + "name": "y2", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "navigator_corridor_building_id_idx": { + "name": "navigator_corridor_building_id_idx", + "columns": [ + { + "expression": "building_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "navigator_corridor_building_id_building_id_fk": { + "name": "navigator_corridor_building_id_building_id_fk", + "tableFrom": "navigator_corridor", + "tableTo": "building", + "columnsFrom": [ + "building_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.navigator_lift": { + "name": "navigator_lift", + "schema": "", + "columns": { + "building_id": { + "name": "building_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "max_storey": { + "name": "max_storey", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "min_storey": { + "name": "min_storey", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "x": { + "name": "x", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "y": { + "name": "y", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "navigator_lift_building_id_idx": { + "name": "navigator_lift_building_id_idx", + "columns": [ + { + "expression": "building_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "navigator_lift_building_id_building_id_fk": { + "name": "navigator_lift_building_id_building_id_fk", + "tableFrom": "navigator_lift", + "tableTo": "building", + "columnsFrom": [ + "building_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.navigator_stair": { + "name": "navigator_stair", + "schema": "", + "columns": { + "building_id": { + "name": "building_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "max_storey": { + "name": "max_storey", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "min_storey": { + "name": "min_storey", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rotation": { + "name": "rotation", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "x": { + "name": "x", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "y": { + "name": "y", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "navigator_stair_building_id_idx": { + "name": "navigator_stair_building_id_idx", + "columns": [ + { + "expression": "building_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "navigator_stair_building_id_building_id_fk": { + "name": "navigator_stair_building_id_building_id_fk", + "tableFrom": "navigator_stair", + "tableTo": "building", + "columnsFrom": [ + "building_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.navigator_translation": { + "name": "navigator_translation", + "schema": "", + "columns": { + "lang_key": { + "name": "lang_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text_key": { + "name": "text_key", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "navigator_translation_lang_key_text_key_pk": { + "name": "navigator_translation_lang_key_text_key_pk", + "columns": [ + "lang_key", + "text_key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.announcement": { + "name": "announcement", + "schema": "", + "columns": { + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "highlighted": { + "name": "highlighted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "image_byte_size": { + "name": "image_byte_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "image_content_type": { + "name": "image_content_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_key": { + "name": "image_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_updated_at": { + "name": "image_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "kiosk_only": { + "name": "kiosk_only", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "valid_from": { + "name": "valid_from", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "valid_until": { + "name": "valid_until", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "announcement_author_id_user_id_fk": { + "name": "announcement_author_id_user_id_fk", + "tableFrom": "announcement", + "tableTo": "user", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "announcement_image_complete": { + "name": "announcement_image_complete", + "value": "(\"announcement\".\"image_key\" IS NULL) = (\"announcement\".\"image_content_type\" IS NULL)\n AND (\"announcement\".\"image_key\" IS NULL) = (\"announcement\".\"image_byte_size\" IS NULL)\n AND (\"announcement\".\"image_key\" IS NULL) = (\"announcement\".\"image_updated_at\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.announcement_cohort_mtm": { + "name": "announcement_cohort_mtm", + "schema": "", + "columns": { + "announcement_id": { + "name": "announcement_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cohort_id": { + "name": "cohort_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "announcement_cohort_mtm_announcement_id_idx": { + "name": "announcement_cohort_mtm_announcement_id_idx", + "columns": [ + { + "expression": "announcement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "announcement_cohort_mtm_cohort_id_idx": { + "name": "announcement_cohort_mtm_cohort_id_idx", + "columns": [ + { + "expression": "cohort_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "announcement_cohort_mtm_announcement_id_announcement_id_fk": { + "name": "announcement_cohort_mtm_announcement_id_announcement_id_fk", + "tableFrom": "announcement_cohort_mtm", + "tableTo": "announcement", + "columnsFrom": [ + "announcement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "announcement_cohort_mtm_cohort_id_cohort_id_fk": { + "name": "announcement_cohort_mtm_cohort_id_cohort_id_fk", + "tableFrom": "announcement_cohort_mtm", + "tableTo": "cohort", + "columnsFrom": [ + "cohort_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "announcement_cohort_mtm_announcement_id_cohort_id_pk": { + "name": "announcement_cohort_mtm_announcement_id_cohort_id_pk", + "columns": [ + "announcement_id", + "cohort_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.announcement_kiosk_mtm": { + "name": "announcement_kiosk_mtm", + "schema": "", + "columns": { + "announcement_id": { + "name": "announcement_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kiosk_id": { + "name": "kiosk_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "announcement_kiosk_mtm_announcement_id_idx": { + "name": "announcement_kiosk_mtm_announcement_id_idx", + "columns": [ + { + "expression": "announcement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "announcement_kiosk_mtm_kiosk_id_idx": { + "name": "announcement_kiosk_mtm_kiosk_id_idx", + "columns": [ + { + "expression": "kiosk_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "announcement_kiosk_mtm_announcement_id_announcement_id_fk": { + "name": "announcement_kiosk_mtm_announcement_id_announcement_id_fk", + "tableFrom": "announcement_kiosk_mtm", + "tableTo": "announcement", + "columnsFrom": [ + "announcement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "announcement_kiosk_mtm_kiosk_id_kiosk_id_fk": { + "name": "announcement_kiosk_mtm_kiosk_id_kiosk_id_fk", + "tableFrom": "announcement_kiosk_mtm", + "tableTo": "kiosk", + "columnsFrom": [ + "kiosk_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "announcement_kiosk_mtm_announcement_id_kiosk_id_pk": { + "name": "announcement_kiosk_mtm_announcement_id_kiosk_id_pk", + "columns": [ + "announcement_id", + "kiosk_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blog_post": { + "name": "blog_post", + "schema": "", + "columns": { + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "published_at": { + "name": "published_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "blog_post_author_id_user_id_fk": { + "name": "blog_post_author_id_user_id_fk", + "tableFrom": "blog_post", + "tableTo": "user", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "blog_post_slug_unique": { + "name": "blog_post_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_message": { + "name": "system_message", + "schema": "", + "columns": { + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "valid_from": { + "name": "valid_from", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "valid_until": { + "name": "valid_until", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "system_message_author_id_user_id_fk": { + "name": "system_message_author_id_user_id_fk", + "tableFrom": "system_message", + "tableTo": "user", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_message_cohort_mtm": { + "name": "system_message_cohort_mtm", + "schema": "", + "columns": { + "cohort_id": { + "name": "cohort_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "system_message_id": { + "name": "system_message_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "system_message_cohort_mtm_system_message_id_idx": { + "name": "system_message_cohort_mtm_system_message_id_idx", + "columns": [ + { + "expression": "system_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "system_message_cohort_mtm_cohort_id_idx": { + "name": "system_message_cohort_mtm_cohort_id_idx", + "columns": [ + { + "expression": "cohort_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "system_message_cohort_mtm_cohort_id_cohort_id_fk": { + "name": "system_message_cohort_mtm_cohort_id_cohort_id_fk", + "tableFrom": "system_message_cohort_mtm", + "tableTo": "cohort", + "columnsFrom": [ + "cohort_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "system_message_cohort_mtm_system_message_id_system_message_id_fk": { + "name": "system_message_cohort_mtm_system_message_id_system_message_id_fk", + "tableFrom": "system_message_cohort_mtm", + "tableTo": "system_message", + "columnsFrom": [ + "system_message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "system_message_cohort_mtm_system_message_id_cohort_id_pk": { + "name": "system_message_cohort_mtm_system_message_id_cohort_id_pk", + "columns": [ + "system_message_id", + "cohort_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.fcm_token": { + "name": "fcm_token", + "schema": "", + "columns": { + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "device_info": { + "name": "device_info", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "fcm_token_user_id_token_unique": { + "name": "fcm_token_user_id_token_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "fcm_token_user_id_user_id_fk": { + "name": "fcm_token_user_id_user_id_fk", + "tableFrom": "fcm_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification": { + "name": "notification", + "schema": "", + "columns": { + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "read": { + "name": "read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "notification_user_id_created_at_idx": { + "name": "notification_user_id_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_created_at_idx": { + "name": "notification_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_user_id_user_id_fk": { + "name": "notification_user_id_user_id_fk", + "tableFrom": "notification", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_preferences": { + "name": "user_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'hu'" + }, + "notification_preferences": { + "name": "notification_preferences", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"announcement\":true,\"blogPost\":false,\"channelsEnabled\":true,\"doorlockCardUsed\":false,\"movedLesson\":true,\"substitution\":true,\"systemMessage\":true}'::jsonb" + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "timetable_class_colors": { + "name": "timetable_class_colors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "timetable_group_display": { + "name": "timetable_group_display", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'highlight'" + }, + "timetable_view": { + "name": "timetable_view", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'class'" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_user_id_fk": { + "name": "user_preferences_user_id_user_id_fk", + "tableFrom": "user_preferences", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_preferences_user_id_unique": { + "name": "user_preferences_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.building": { + "name": "building", + "schema": "", + "columns": { + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mapped": { + "name": "mapped", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "x": { + "name": "x", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "y": { + "name": "y", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.classroom": { + "name": "classroom", + "schema": "", + "columns": { + "building_id": { + "name": "building_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "capacity": { + "name": "capacity", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mapped": { + "name": "mapped", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rotation": { + "name": "rotation", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "short": { + "name": "short", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "size_x": { + "name": "size_x", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 6 + }, + "size_y": { + "name": "size_y", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 6 + }, + "size_z": { + "name": "size_z", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "storey": { + "name": "storey", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "type_id": { + "name": "type_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "x": { + "name": "x", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "y": { + "name": "y", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "classroom_building_id_idx": { + "name": "classroom_building_id_idx", + "columns": [ + { + "expression": "building_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "classroom_type_id_idx": { + "name": "classroom_type_id_idx", + "columns": [ + { + "expression": "type_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "classroom_building_id_building_id_fk": { + "name": "classroom_building_id_building_id_fk", + "tableFrom": "classroom", + "tableTo": "building", + "columnsFrom": [ + "building_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "classroom_type_id_classroom_type_id_fk": { + "name": "classroom_type_id_classroom_type_id_fk", + "tableFrom": "classroom", + "tableTo": "classroom_type", + "columnsFrom": [ + "type_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.classroom_type": { + "name": "classroom_type", + "schema": "", + "columns": { + "colorhex": { + "name": "colorhex", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cohort": { + "name": "cohort", + "schema": "", + "columns": { + "classroom_ids": { + "name": "classroom_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "short": { + "name": "short", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "teacher_id": { + "name": "teacher_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timetable_id": { + "name": "timetable_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "cohort_teacher_id_teacher_id_fk": { + "name": "cohort_teacher_id_teacher_id_fk", + "tableFrom": "cohort", + "tableTo": "teacher", + "columnsFrom": [ + "teacher_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cohort_timetable_id_timetable_id_fk": { + "name": "cohort_timetable_id_timetable_id_fk", + "tableFrom": "cohort", + "tableTo": "timetable", + "columnsFrom": [ + "timetable_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.group": { + "name": "group", + "schema": "", + "columns": { + "cohort_id": { + "name": "cohort_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "division_tag": { + "name": "division_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entire_class": { + "name": "entire_class", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "student_count": { + "name": "student_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "teacher_id": { + "name": "teacher_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "group_cohort_id_cohort_id_fk": { + "name": "group_cohort_id_cohort_id_fk", + "tableFrom": "group", + "tableTo": "cohort", + "columnsFrom": [ + "cohort_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "group_teacher_id_teacher_id_fk": { + "name": "group_teacher_id_teacher_id_fk", + "tableFrom": "group", + "tableTo": "teacher", + "columnsFrom": [ + "teacher_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cohort_timetable_mtm": { + "name": "cohort_timetable_mtm", + "schema": "", + "columns": { + "cohort_id": { + "name": "cohort_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timetable_id": { + "name": "timetable_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "cohort_timetable_mtm_timetable_id_idx": { + "name": "cohort_timetable_mtm_timetable_id_idx", + "columns": [ + { + "expression": "timetable_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cohort_timetable_mtm_cohort_id_cohort_id_fk": { + "name": "cohort_timetable_mtm_cohort_id_cohort_id_fk", + "tableFrom": "cohort_timetable_mtm", + "tableTo": "cohort", + "columnsFrom": [ + "cohort_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cohort_timetable_mtm_timetable_id_timetable_id_fk": { + "name": "cohort_timetable_mtm_timetable_id_timetable_id_fk", + "tableFrom": "cohort_timetable_mtm", + "tableTo": "timetable", + "columnsFrom": [ + "timetable_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "cohort_timetable_mtm_cohort_id_timetable_id_pk": { + "name": "cohort_timetable_mtm_cohort_id_timetable_id_pk", + "columns": [ + "cohort_id", + "timetable_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.day_definition": { + "name": "day_definition", + "schema": "", + "columns": { + "days": { + "name": "days", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "short": { + "name": "short", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lesson": { + "name": "lesson", + "schema": "", + "columns": { + "classroom_ids": { + "name": "classroom_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "day_definition_id": { + "name": "day_definition_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_ids": { + "name": "group_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "period_id": { + "name": "period_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "periods_per_week": { + "name": "periods_per_week", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "teacher_ids": { + "name": "teacher_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "term_definition_id": { + "name": "term_definition_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timetable_id": { + "name": "timetable_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "weeks_definition_id": { + "name": "weeks_definition_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "lesson_day_definition_id_day_definition_id_fk": { + "name": "lesson_day_definition_id_day_definition_id_fk", + "tableFrom": "lesson", + "tableTo": "day_definition", + "columnsFrom": [ + "day_definition_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "lesson_period_id_period_id_fk": { + "name": "lesson_period_id_period_id_fk", + "tableFrom": "lesson", + "tableTo": "period", + "columnsFrom": [ + "period_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "lesson_subject_id_subject_id_fk": { + "name": "lesson_subject_id_subject_id_fk", + "tableFrom": "lesson", + "tableTo": "subject", + "columnsFrom": [ + "subject_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "lesson_term_definition_id_term_definition_id_fk": { + "name": "lesson_term_definition_id_term_definition_id_fk", + "tableFrom": "lesson", + "tableTo": "term_definition", + "columnsFrom": [ + "term_definition_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "lesson_timetable_id_timetable_id_fk": { + "name": "lesson_timetable_id_timetable_id_fk", + "tableFrom": "lesson", + "tableTo": "timetable", + "columnsFrom": [ + "timetable_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "lesson_weeks_definition_id_week_definition_id_fk": { + "name": "lesson_weeks_definition_id_week_definition_id_fk", + "tableFrom": "lesson", + "tableTo": "week_definition", + "columnsFrom": [ + "weeks_definition_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lesson_cohort_mtm": { + "name": "lesson_cohort_mtm", + "schema": "", + "columns": { + "cohort_id": { + "name": "cohort_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lesson_id": { + "name": "lesson_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "lesson_cohort_mtm_cohort_id_cohort_id_fk": { + "name": "lesson_cohort_mtm_cohort_id_cohort_id_fk", + "tableFrom": "lesson_cohort_mtm", + "tableTo": "cohort", + "columnsFrom": [ + "cohort_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "lesson_cohort_mtm_lesson_id_lesson_id_fk": { + "name": "lesson_cohort_mtm_lesson_id_lesson_id_fk", + "tableFrom": "lesson_cohort_mtm", + "tableTo": "lesson", + "columnsFrom": [ + "lesson_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "lesson_cohort_mtm_lesson_id_cohort_id_pk": { + "name": "lesson_cohort_mtm_lesson_id_cohort_id_pk", + "columns": [ + "lesson_id", + "cohort_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.moved_lesson": { + "name": "moved_lesson", + "schema": "", + "columns": { + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "room": { + "name": "room", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "starting_day": { + "name": "starting_day", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "starting_period": { + "name": "starting_period", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "moved_lesson_room_classroom_id_fk": { + "name": "moved_lesson_room_classroom_id_fk", + "tableFrom": "moved_lesson", + "tableTo": "classroom", + "columnsFrom": [ + "room" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "moved_lesson_starting_day_day_definition_id_fk": { + "name": "moved_lesson_starting_day_day_definition_id_fk", + "tableFrom": "moved_lesson", + "tableTo": "day_definition", + "columnsFrom": [ + "starting_day" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "moved_lesson_starting_period_period_id_fk": { + "name": "moved_lesson_starting_period_period_id_fk", + "tableFrom": "moved_lesson", + "tableTo": "period", + "columnsFrom": [ + "starting_period" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.moved_lesson_lesson_mtm": { + "name": "moved_lesson_lesson_mtm", + "schema": "", + "columns": { + "lesson_id": { + "name": "lesson_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "moved_lesson_id": { + "name": "moved_lesson_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "moved_lesson_lesson_mtm_lesson_id_lesson_id_fk": { + "name": "moved_lesson_lesson_mtm_lesson_id_lesson_id_fk", + "tableFrom": "moved_lesson_lesson_mtm", + "tableTo": "lesson", + "columnsFrom": [ + "lesson_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "moved_lesson_lesson_mtm_moved_lesson_id_moved_lesson_id_fk": { + "name": "moved_lesson_lesson_mtm_moved_lesson_id_moved_lesson_id_fk", + "tableFrom": "moved_lesson_lesson_mtm", + "tableTo": "moved_lesson", + "columnsFrom": [ + "moved_lesson_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "moved_lesson_lesson_mtm_lesson_id_moved_lesson_id_pk": { + "name": "moved_lesson_lesson_mtm_lesson_id_moved_lesson_id_pk", + "columns": [ + "lesson_id", + "moved_lesson_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.period": { + "name": "period", + "schema": "", + "columns": { + "end_time": { + "name": "end_time", + "type": "time", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "period": { + "name": "period", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "start_time": { + "name": "start_time", + "type": "time", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subject": { + "name": "subject", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "short": { + "name": "short", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.substitution": { + "name": "substitution", + "schema": "", + "columns": { + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "substituter": { + "name": "substituter", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "substitution_substituter_teacher_id_fk": { + "name": "substitution_substituter_teacher_id_fk", + "tableFrom": "substitution", + "tableTo": "teacher", + "columnsFrom": [ + "substituter" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.substitution_lesson_mtm": { + "name": "substitution_lesson_mtm", + "schema": "", + "columns": { + "lesson_id": { + "name": "lesson_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "substitution_id": { + "name": "substitution_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "substitution_lesson_mtm_lesson_id_lesson_id_fk": { + "name": "substitution_lesson_mtm_lesson_id_lesson_id_fk", + "tableFrom": "substitution_lesson_mtm", + "tableTo": "lesson", + "columnsFrom": [ + "lesson_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "substitution_lesson_mtm_substitution_id_substitution_id_fk": { + "name": "substitution_lesson_mtm_substitution_id_substitution_id_fk", + "tableFrom": "substitution_lesson_mtm", + "tableTo": "substitution", + "columnsFrom": [ + "substitution_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "substitution_lesson_mtm_lesson_id_substitution_id_pk": { + "name": "substitution_lesson_mtm_lesson_id_substitution_id_pk", + "columns": [ + "lesson_id", + "substitution_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.teacher": { + "name": "teacher", + "schema": "", + "columns": { + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "gender": { + "name": "gender", + "type": "bit(1)", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "short": { + "name": "short", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "teacher_user_id_user_id_fk": { + "name": "teacher_user_id_user_id_fk", + "tableFrom": "teacher", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.term_definition": { + "name": "term_definition", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "short": { + "name": "short", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "terms": { + "name": "terms", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.timetable": { + "name": "timetable", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "valid_from": { + "name": "valid_from", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "valid_to": { + "name": "valid_to", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_group": { + "name": "user_group", + "schema": "", + "columns": { + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "user_group_user_id_idx": { + "name": "user_group_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_group_group_id_idx": { + "name": "user_group_group_id_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_group_group_id_group_id_fk": { + "name": "user_group_group_id_group_id_fk", + "tableFrom": "user_group", + "tableTo": "group", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_group_user_id_user_id_fk": { + "name": "user_group_user_id_user_id_fk", + "tableFrom": "user_group", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_group_group_id_user_id_pk": { + "name": "user_group_group_id_user_id_pk", + "columns": [ + "group_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.week_definition": { + "name": "week_definition", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "short": { + "name": "short", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "weeks": { + "name": "weeks", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wifi_auth_log": { + "name": "wifi_auth_log", + "schema": "", + "columns": { + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "wifi_auth_log_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "mac_address": { + "name": "mac_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "nas_ip_address": { + "name": "nas_ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nas_mac_address": { + "name": "nas_mac_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "wifi_user_id": { + "name": "wifi_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "wifi_auth_log_timestamp_idx": { + "name": "wifi_auth_log_timestamp_idx", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wifi_auth_log_username_idx": { + "name": "wifi_auth_log_username_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wifi_auth_log_mac_address_idx": { + "name": "wifi_auth_log_mac_address_idx", + "columns": [ + { + "expression": "mac_address", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wifi_auth_log_wifi_user_id_wifi_user_id_fk": { + "name": "wifi_auth_log_wifi_user_id_wifi_user_id_fk", + "tableFrom": "wifi_auth_log", + "tableTo": "wifi_user", + "columnsFrom": [ + "wifi_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wifi_device": { + "name": "wifi_device", + "schema": "", + "columns": { + "admin_notes": { + "name": "admin_notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "mac_address": { + "name": "mac_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "nickname": { + "name": "nickname", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reported_hostname": { + "name": "reported_hostname", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wifi_user_id": { + "name": "wifi_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wifi_device_mac_address_idx": { + "name": "wifi_device_mac_address_idx", + "columns": [ + { + "expression": "mac_address", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wifi_device_wifi_user_id_idx": { + "name": "wifi_device_wifi_user_id_idx", + "columns": [ + { + "expression": "wifi_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wifi_device_wifi_user_id_wifi_user_id_fk": { + "name": "wifi_device_wifi_user_id_wifi_user_id_fk", + "tableFrom": "wifi_device", + "tableTo": "wifi_user", + "columnsFrom": [ + "wifi_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "wifi_device_mac_address_unique": { + "name": "wifi_device_mac_address_unique", + "nullsNotDistinct": false, + "columns": [ + "mac_address" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wifi_nas": { + "name": "wifi_nas", + "schema": "", + "columns": { + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mac_address": { + "name": "mac_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wifi_nas_mac_address_idx": { + "name": "wifi_nas_mac_address_idx", + "columns": [ + { + "expression": "mac_address", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wifi_nas_ip_address_idx": { + "name": "wifi_nas_ip_address_idx", + "columns": [ + { + "expression": "ip_address", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "wifi_nas_mac_address_unique": { + "name": "wifi_nas_mac_address_unique", + "nullsNotDistinct": false, + "columns": [ + "mac_address" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wifi_role_speed_profile": { + "name": "wifi_role_speed_profile", + "schema": "", + "columns": { + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "role_name": { + "name": "role_name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "speed_profile_id": { + "name": "speed_profile_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wifi_role_speed_profile_role_name_idx": { + "name": "wifi_role_speed_profile_role_name_idx", + "columns": [ + { + "expression": "role_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wifi_role_speed_profile_speed_profile_id_wifi_speed_profile_id_fk": { + "name": "wifi_role_speed_profile_speed_profile_id_wifi_speed_profile_id_fk", + "tableFrom": "wifi_role_speed_profile", + "tableTo": "wifi_speed_profile", + "columnsFrom": [ + "speed_profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wifi_speed_profile": { + "name": "wifi_speed_profile", + "schema": "", + "columns": { + "download_speed_mbps": { + "name": "download_speed_mbps", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "is_wlan_default": { + "name": "is_wlan_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "synced_at": { + "name": "synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "upload_speed_mbps": { + "name": "upload_speed_mbps", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wifi_user": { + "name": "wifi_user", + "schema": "", + "columns": { + "allowed_mac_addresses": { + "name": "allowed_mac_addresses", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "salt": { + "name": "salt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "speed_profile_id": { + "name": "speed_profile_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wifi_user_username_idx": { + "name": "wifi_user_username_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wifi_user_user_id_idx": { + "name": "wifi_user_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wifi_user_created_by_user_id_fk": { + "name": "wifi_user_created_by_user_id_fk", + "tableFrom": "wifi_user", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "wifi_user_user_id_user_id_fk": { + "name": "wifi_user_user_id_user_id_fk", + "tableFrom": "wifi_user", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "wifi_user_username_unique": { + "name": "wifi_user_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/chronos/src/database/migrations/meta/_journal.json b/apps/chronos/src/database/migrations/meta/_journal.json index 2ee46679..ae519587 100644 --- a/apps/chronos/src/database/migrations/meta/_journal.json +++ b/apps/chronos/src/database/migrations/meta/_journal.json @@ -176,6 +176,13 @@ "when": 1791447969715, "tag": "0024_moaning_marvel_boy", "breakpoints": true + }, + { + "idx": 25, + "version": "7", + "when": 1792151024241, + "tag": "0025_mean_wildside", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/chronos/src/index.ts b/apps/chronos/src/index.ts index 9f28cca6..42a63f5c 100644 --- a/apps/chronos/src/index.ts +++ b/apps/chronos/src/index.ts @@ -9,7 +9,6 @@ import type { RPCHandlerOptions, } from '@orpc/server/fetch'; import { RPCHandler } from '@orpc/server/fetch'; -import { CORSPlugin, ResponseHeadersPlugin } from '@orpc/server/plugins'; import { ZodToJsonSchemaConverter } from '@orpc/zod/zod4'; import type { ChronosContext } from '#_types/globals'; import { prepareDb } from '#database'; @@ -29,6 +28,12 @@ import { env } from '#utils/environment'; import { configureLogger } from '#utils/logger'; import { initializeNotificationEngine } from '#utils/notifications/initialize'; import { openApiSpecOptions } from '#utils/openapi-spec'; +import { + corsPlugins, + MAX_REQUEST_BODY_BYTES, + resolveClientIp, + withSecurityHeaders, +} from '#utils/security'; import { initSentry } from '#utils/telemetry'; await configureLogger('chronos'); @@ -43,43 +48,7 @@ if (env.mode === 'development') { await prepareDb(); await initializeRBAC(); setupCronJobs(); -initializeNotificationEngine(); - -/** - * The header set the pre-oRPC `secureHeaders` middleware produced, applied to - * every response the server emits. `script-src-elem`/`style-src-elem` keep - * allowing the CDN bundles Swagger UI loads, and `Cross-Origin-Resource-Policy: - * cross-origin` keeps announcement images embeddable by the kiosk build, which - * is served from a host of its own. - */ -const SECURITY_HEADERS: Readonly> = { - 'Content-Security-Policy': - "base-uri 'self'; child-src 'self'; connect-src 'self'; default-src 'self'; font-src 'self' https: data:; form-action 'self'; frame-ancestors 'self'; frame-src 'self'; img-src 'self' data:; manifest-src 'self'; media-src 'self'; object-src 'none'; report-to endpoint-1; sandbox allow-same-origin allow-scripts; script-src 'self'; script-src-attr 'none'; script-src-elem 'self' https: 'unsafe-inline'; style-src 'self' https: 'unsafe-inline'; style-src-attr 'none'; style-src-elem 'self' https: 'unsafe-inline'; upgrade-insecure-requests; worker-src 'self'", - 'Cross-Origin-Opener-Policy': 'same-origin', - 'Cross-Origin-Resource-Policy': 'cross-origin', - 'Origin-Agent-Cluster': '?1', - 'Referrer-Policy': 'no-referrer', - 'Strict-Transport-Security': 'max-age=15552000; includeSubDomains', - 'X-Content-Type-Options': 'nosniff', - 'X-DNS-Prefetch-Control': 'off', - 'X-Download-Options': 'noopen', - 'X-Frame-Options': 'SAMEORIGIN', - 'X-Permitted-Cross-Domain-Policies': 'none', - 'X-XSS-Protection': '0', -}; - -const withSecurityHeaders = (response: Response): Response => { - const headers = new Headers(response.headers); - for (const [name, value] of Object.entries(SECURITY_HEADERS)) { - headers.set(name, value); - } - - return new Response(response.body, { - headers, - status: response.status, - statusText: response.statusText, - }); -}; +await initializeNotificationEngine(); const isDevelopment = env.mode === 'development'; @@ -133,9 +102,9 @@ type AdapterInterceptor = NonNullable< >[number]; /** - * Request log: trace line in development, structured line otherwise. Carries - * the request id so it can be joined to the error boundary's line and to a - * client's `x-request-id` response header. + * Request log: `debug` line in development, structured `info` line otherwise. + * Carries the request id so it can be joined to the error boundary's line and + * to a client's `x-request-id` response header. */ const timingInterceptor: AdapterInterceptor = async (options) => { const start = Date.now(); @@ -150,7 +119,7 @@ const timingInterceptor: AdapterInterceptor = async (options) => { } = options; if (isDevelopment) { - logger.trace(`${method} ${url} - ${ms}ms`, { + logger.debug(`${method} ${url} - ${ms}ms`, { duration: ms, method, requestId, @@ -176,19 +145,6 @@ const timingInterceptor: AdapterInterceptor = async (options) => { return result; }; -const corsPlugins = () => [ - new CORSPlugin({ - allowHeaders: ['Content-Type', 'Authorization'], - allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], - origin: isDevelopment - ? (origin) => origin - : [env.baseUrl, ...(env.trustedOrigins ?? [])], - }), - // Lets a procedure set response headers (`context.resHeaders`), which the - // announcement image uses for its long-lived Cache-Control. - new ResponseHeadersPlugin(), -]; - const openApiPlugin = new OpenAPIReferencePlugin({ docsPath: '/doc/swagger', docsProvider: 'swagger', @@ -210,32 +166,18 @@ export const openApiHandler = new OpenAPIHandler(appRouter, { plugins: [...corsPlugins(), openApiPlugin], }); -/** - * Everything the transport knows before a procedure runs. Built per request, - * once, only for the requests that reach an oRPC handler. - */ const buildContext = async ( request: Request, srv: Bun.Server ): Promise => ({ ...(await resolveCaller(request.headers)), - clientIp: env.realIpHeader - ? (request.headers.get(env.realIpHeader) ?? '') - : (srv.requestIP(request)?.address ?? 'unknown'), + clientIp: resolveClientIp(request, srv), reqHeaders: request.headers, // Echoed on the response as `x-request-id`, so a client can name the exact // request it is reporting and the line can be found in the log stream. requestId: request.headers.get('x-request-id') ?? crypto.randomUUID(), }); -/** - * Ceiling for any request body, enforced by Bun before the handler buffers it. - * The largest legitimate upload is the 20 MiB timetable import - * (`modules/timetable/import.ts`); Bun's own default is 128 MiB, which is a lot - * of memory to hand a single unauthenticated-shaped request. - */ -const MAX_REQUEST_BODY_BYTES = 25 * 1024 * 1024; - export const server = Bun.serve({ fetch: async (request, srv) => { const url = new URL(request.url); @@ -248,13 +190,16 @@ export const server = Bun.serve({ // better-auth owns /api/auth/*; it answers its own JSON and HTML. if (url.pathname.startsWith('/api/auth/')) { - return withSecurityHeaders(await auth.handler(request)); + return withSecurityHeaders(await auth.handler(request), url.pathname); } // The unsubscribe pages are HTML forms and links from emails: no envelope, // no oRPC. if (url.pathname === '/api/notifications/unsubscribe') { - return withSecurityHeaders(await handleUnsubscribe(request)); + return withSecurityHeaders( + await handleUnsubscribe(request), + url.pathname + ); } const requestId = @@ -267,7 +212,8 @@ export const server = Bun.serve({ const final = withSecurityHeaders( matched && response ? response - : new Response('Not found', { status: 404 }) + : new Response('Not found', { status: 404 }), + url.pathname ); final.headers.set('x-request-id', requestId); @@ -279,9 +225,6 @@ export const server = Bun.serve({ }); logger.info(`chronos listening on http://localhost:${env.port}`); -if (env.logLevel === 'trace') { - logger.info('Log level set to TRACE, verbose request logging enabled'); -} const handleShutdown = async () => { logger.info('Shutting down chronos...'); diff --git a/apps/chronos/src/modules/api-keys/index.ts b/apps/chronos/src/modules/api-keys/index.ts index 4b76d9f4..1b83d572 100644 --- a/apps/chronos/src/modules/api-keys/index.ts +++ b/apps/chronos/src/modules/api-keys/index.ts @@ -71,7 +71,7 @@ export const listAdminApiKeys = base.adminApiKeys.list .innerJoin(user, sql`${apikey.referenceId} = ${user.id}::text`) .where(whereClause); - return { apiKeys, total: totalRow?.count ?? 0 }; + return { items: apiKeys, total: totalRow?.count ?? 0 }; }); export const updateAdminApiKey = base.adminApiKeys.update diff --git a/apps/chronos/src/modules/bug-report/index.ts b/apps/chronos/src/modules/bug-report/index.ts index 9e477e3b..0677a7ee 100644 --- a/apps/chronos/src/modules/bug-report/index.ts +++ b/apps/chronos/src/modules/bug-report/index.ts @@ -85,7 +85,7 @@ export const listBugReports = base.bugReport.list db.select({ count: count() }).from(bugReport).where(where), ]); - return { reports, total: totalResult[0]?.count ?? 0 }; + return { items: reports, total: totalResult[0]?.count ?? 0 }; }); export const updateBugReportStatus = base.bugReport.updateStatus diff --git a/apps/chronos/src/modules/bug-report/schema.ts b/apps/chronos/src/modules/bug-report/schema.ts index 91856a38..a641d669 100644 --- a/apps/chronos/src/modules/bug-report/schema.ts +++ b/apps/chronos/src/modules/bug-report/schema.ts @@ -1,4 +1,5 @@ import { index, jsonb, pgTable, text, uuid } from 'drizzle-orm/pg-core'; +import type { JSONType } from 'zod'; import { timestamps } from '#database/helpers'; import { user } from '#database/schema/authentication'; @@ -7,7 +8,7 @@ export const bugReport = pgTable( { description: text('description').notNull(), id: uuid('id').primaryKey().defaultRandom(), - metadata: jsonb('metadata'), + metadata: jsonb('metadata').$type(), page: text('page'), reporterEmail: text('reporter_email'), reporterId: uuid('reporter_id').references(() => user.id, { diff --git a/apps/chronos/src/modules/cohort/index.ts b/apps/chronos/src/modules/cohort/index.ts index 593d36f6..768d8b80 100644 --- a/apps/chronos/src/modules/cohort/index.ts +++ b/apps/chronos/src/modules/cohort/index.ts @@ -5,5 +5,5 @@ import { base } from '#orpc'; export const listCohorts = base.cohort.cohort.handler(async () => { const cohorts = await db.select().from(cohort); - return cohorts; + return { items: cohorts, total: cohorts.length }; }); diff --git a/apps/chronos/src/modules/dashboard/index.ts b/apps/chronos/src/modules/dashboard/index.ts index d2f12a09..be80a582 100644 --- a/apps/chronos/src/modules/dashboard/index.ts +++ b/apps/chronos/src/modules/dashboard/index.ts @@ -5,13 +5,7 @@ import { role } from '#database/schema/authorization'; import { cohort, movedLesson, substitution } from '#modules/timetable/schema'; import { getActiveTimetableId } from '#modules/timetable/utils/active'; import { base } from '#orpc'; - -function fmtDate(d: Date): string { - const y = d.getFullYear(); - const m = String(d.getMonth() + 1).padStart(2, '0'); - const day = String(d.getDate()).padStart(2, '0'); - return `${y}-${m}-${day}`; -} +import { toDateOnlyString } from '#utils/date'; export const getDashboardStats = base.dashboard.stats.handler( async ({ input }) => { @@ -77,14 +71,14 @@ export const getDashboardStats = base.dashboard.stats.handler( >(); for (const sub of relevantSubs) { - const d = fmtDate(sub.date); + const d = toDateOnlyString(sub.date); const entry = dateMap.get(d) ?? { movedLessons: 0, substitutions: 0 }; entry.substitutions += 1; dateMap.set(d, entry); } for (const m of movedLessonDates) { - const d = fmtDate(m.date); + const d = toDateOnlyString(m.date); const entry = dateMap.get(d) ?? { movedLessons: 0, substitutions: 0 }; entry.movedLessons += 1; dateMap.set(d, entry); diff --git a/apps/chronos/src/modules/doorlock/_notifications.ts b/apps/chronos/src/modules/doorlock/_notifications.ts index 1c21c9be..33c6942a 100644 --- a/apps/chronos/src/modules/doorlock/_notifications.ts +++ b/apps/chronos/src/modules/doorlock/_notifications.ts @@ -1,4 +1,4 @@ -import { resolveAudienceForDoorlock } from '#utils/notifications/engine'; +import { resolveAudienceForUser } from '#utils/notifications/engine'; import { isHungarianLocale } from '#utils/notifications/locale'; import type { NotificationHandler } from '#utils/notifications/types'; @@ -29,7 +29,7 @@ export const doorlockNotifications: readonly NotificationHandler[] = [ : 'Card Used', }; }, - getAudience: (payload) => resolveAudienceForDoorlock(payload), + getAudience: (payload) => resolveAudienceForUser(payload), getDelay: () => 0, preferenceKey: 'doorlockCardUsed', type: 'doorlock_card_used', diff --git a/apps/chronos/src/modules/doorlock/cards.ts b/apps/chronos/src/modules/doorlock/cards.ts index b6ca73f5..b61580b0 100644 --- a/apps/chronos/src/modules/doorlock/cards.ts +++ b/apps/chronos/src/modules/doorlock/cards.ts @@ -28,7 +28,7 @@ export const listCards = base.doorlock.cards.list .handler(async () => { const cards = await fetchCards(); - return { cards }; + return { items: cards, total: cards.length }; }); export const listDoorlockUsers = base.doorlock.cards.users @@ -44,7 +44,7 @@ export const listDoorlockUsers = base.doorlock.cards.users .from(user) .orderBy(sql`coalesce(${user.nickname}, ${user.name})`); - return { users: usersList }; + return { items: usersList, total: usersList.length }; }); export const createCard = base.doorlock.cards.create @@ -88,7 +88,7 @@ export const createCard = base.doorlock.cards.create ) ); - return { card: createdCard }; + return createdCard; }); export const updateCard = base.doorlock.cards.update @@ -123,7 +123,7 @@ export const updateCard = base.doorlock.cards.update ) ); - return { card: updatedCard }; + return updatedCard; }); export const deleteCard = base.doorlock.cards.delete diff --git a/apps/chronos/src/modules/doorlock/device-socket.ts b/apps/chronos/src/modules/doorlock/device-socket.ts index 01065dd4..fa2cba66 100644 --- a/apps/chronos/src/modules/doorlock/device-socket.ts +++ b/apps/chronos/src/modules/doorlock/device-socket.ts @@ -75,22 +75,24 @@ const handleIncomingMessage = async ( }; logger.trace('Persisting device health ping', { device }); - await db.insert(deviceHealth).values({ - deviceId: device.id, - deviceMeta: { - ...deserialized.data, - debug: { - ...deserialized.data.debug, - deviceState: getDeviceState(), + await db.transaction(async (tx) => { + await tx.insert(deviceHealth).values({ + deviceId: device.id, + deviceMeta: { + ...deserialized.data, + debug: { + ...deserialized.data.debug, + deviceState: getDeviceState(), + }, }, - }, - }); + }); - logger.trace('Updating device heartbeat timestamp', { device }); - await db - .update(lockDevice) - .set({ updatedAt: new Date() }) - .where(eq(lockDevice.id, device.id)); + logger.trace('Updating device heartbeat timestamp', { device }); + await tx + .update(lockDevice) + .set({ updatedAt: new Date() }) + .where(eq(lockDevice.id, device.id)); + }); break; } case 'card-read': { @@ -102,25 +104,29 @@ const handleIncomingMessage = async ( .where(eq(card.cardData, deserialized.uid)) .limit(1); - await db.insert(auditLog).values({ - buttonPressed: deserialized.buttonPressed, - cardData: deserialized.uid, - cardId: cardUser?.id ?? null, - deviceId: device.id, - result: deserialized.authorized, - userId: cardUser?.userId ?? null, + // The audit row and the heartbeat belong to one card tap: commit them + // together so a failure cannot leave one without the other. + await db.transaction(async (tx) => { + await tx.insert(auditLog).values({ + buttonPressed: deserialized.buttonPressed, + cardData: deserialized.uid, + cardId: cardUser?.id ?? null, + deviceId: device.id, + result: deserialized.authorized, + userId: cardUser?.userId ?? null, + }); + + logger.trace('Updating device heartbeat timestamp', { device }); + await tx + .update(lockDevice) + .set({ updatedAt: new Date() }) + .where(eq(lockDevice.id, device.id)); }); dispatchImmediateNotification('doorlock_card_used', { deviceName: device.name, userId: cardUser?.userId, }); - - logger.trace('Updating device heartbeat timestamp', { device }); - await db - .update(lockDevice) - .set({ updatedAt: new Date() }) - .where(eq(lockDevice.id, device.id)); break; } default: diff --git a/apps/chronos/src/modules/doorlock/devices.ts b/apps/chronos/src/modules/doorlock/devices.ts index 77c9042b..4c111d25 100644 --- a/apps/chronos/src/modules/doorlock/devices.ts +++ b/apps/chronos/src/modules/doorlock/devices.ts @@ -40,7 +40,7 @@ export const listDevices = base.doorlock.devices.list .from(device) .orderBy(desc(device.updatedAt)); - return { devices }; + return { items: devices, total: devices.length }; }); export const createDevice = base.doorlock.devices.create @@ -58,7 +58,7 @@ export const createDevice = base.doorlock.devices.create }); } - return { device: inserted }; + return inserted; } catch (error) { logger.error('Failed to create device', { error }); const knownError = buildConstraintError(error); @@ -88,7 +88,7 @@ export const updateDevice = base.doorlock.devices.update throw notFound('Device not found'); } - return { device: updated }; + return updated; } catch (error) { logger.error('Failed to update device', { error }); const knownError = buildConstraintError(error); diff --git a/apps/chronos/src/modules/doorlock/export.ts b/apps/chronos/src/modules/doorlock/export.ts index 0c5ece72..2dc88e8a 100644 --- a/apps/chronos/src/modules/doorlock/export.ts +++ b/apps/chronos/src/modules/doorlock/export.ts @@ -1,12 +1,13 @@ import type { ExportQueryInput } from '@filcdev/api/domains/doorlock/export'; import { permissions } from '@filcdev/api/permissions'; import type { SQL } from 'drizzle-orm'; -import { and, desc, eq, gte, ilike, lte, or } from 'drizzle-orm'; +import { desc, eq, gte, ilike, lte, or } from 'drizzle-orm'; import { db } from '#database'; import { user } from '#database/schema/authentication'; import { requireAuthorization } from '#middleware/auth'; import { auditLog, card, device } from '#modules/doorlock/schema'; import { base } from '#orpc'; +import { andAll } from '#utils/drizzle'; const appendBaseFilters = ( filters: SQL[], @@ -60,15 +61,7 @@ const buildWhereClause = ( filters.push(filter); } - if (!filters.length) { - return; - } - - let combined: SQL | undefined; - for (const clause of filters) { - combined = combined ? and(combined, clause) : clause; - } - return combined; + return andAll(filters); }; const CSV_SPECIAL_CHARS = /[",\n\r]/; diff --git a/apps/chronos/src/modules/doorlock/logs.ts b/apps/chronos/src/modules/doorlock/logs.ts index ca0f9888..39ecd202 100644 --- a/apps/chronos/src/modules/doorlock/logs.ts +++ b/apps/chronos/src/modules/doorlock/logs.ts @@ -1,12 +1,13 @@ import type { LogsQueryInput } from '@filcdev/api/domains/doorlock/logs'; import { permissions } from '@filcdev/api/permissions'; import type { SQL } from 'drizzle-orm'; -import { and, desc, eq, gte, ilike, lte, or } from 'drizzle-orm'; +import { count, desc, eq, gte, ilike, lte, or } from 'drizzle-orm'; import { db } from '#database'; import { user } from '#database/schema/authentication'; import { requireAuthorization } from '#middleware/auth'; import { auditLog, card, device } from '#modules/doorlock/schema'; import { base } from '#orpc'; +import { andAll } from '#utils/drizzle'; type DeviceSummary = Pick; type CardSummary = Pick; @@ -68,15 +69,7 @@ const buildWhereClause = (query: LogsQueryInput): SQL | undefined => { filters.push(filter); } - if (!filters.length) { - return; - } - - let combined: SQL | undefined; - for (const clause of filters) { - combined = combined ? and(combined, clause) : clause; - } - return combined; + return andAll(filters); }; const mapRowsToLogs = ( @@ -127,34 +120,40 @@ const mapRowsToLogs = ( export const listLogs = base.doorlock.logs.list .use(requireAuthorization(permissions.doorlockLogsRead)) .handler(async ({ input: query }) => { - const rows = await db - .select({ - buttonPressed: auditLog.buttonPressed, - cardData: auditLog.cardData, - cardId: auditLog.cardId, - deviceId: auditLog.deviceId, - id: auditLog.id, - logCardId: card.id, - logCardName: card.name, - logDeviceId: device.id, - logDeviceName: device.name, - ownerEmail: user.email, - ownerId: user.id, - ownerName: user.name, - ownerNickname: user.nickname, - result: auditLog.result, - timestamp: auditLog.timestamp, - userId: auditLog.userId, - }) - .from(auditLog) - .leftJoin(device, eq(auditLog.deviceId, device.id)) - .leftJoin(card, eq(auditLog.cardId, card.id)) - .leftJoin(user, eq(auditLog.userId, user.id)) - .where(buildWhereClause(query)) - .orderBy(desc(auditLog.timestamp)) - .limit(query.limit); + const where = buildWhereClause(query); + // `total` is the unpaged count: the endpoint caps at `limit`, so the page + // length would hide how much more there is. + const [rows, totalResult] = await Promise.all([ + db + .select({ + buttonPressed: auditLog.buttonPressed, + cardData: auditLog.cardData, + cardId: auditLog.cardId, + deviceId: auditLog.deviceId, + id: auditLog.id, + logCardId: card.id, + logCardName: card.name, + logDeviceId: device.id, + logDeviceName: device.name, + ownerEmail: user.email, + ownerId: user.id, + ownerName: user.name, + ownerNickname: user.nickname, + result: auditLog.result, + timestamp: auditLog.timestamp, + userId: auditLog.userId, + }) + .from(auditLog) + .leftJoin(device, eq(auditLog.deviceId, device.id)) + .leftJoin(card, eq(auditLog.cardId, card.id)) + .leftJoin(user, eq(auditLog.userId, user.id)) + .where(where) + .orderBy(desc(auditLog.timestamp)) + .limit(query.limit), + db.select({ count: count() }).from(auditLog).where(where), + ]); const logs = mapRowsToLogs(rows); - return { logs }; + return { items: logs, total: totalResult[0]?.count ?? 0 }; }); diff --git a/apps/chronos/src/modules/doorlock/self.ts b/apps/chronos/src/modules/doorlock/self.ts index 8deadd93..4ed3eaaa 100644 --- a/apps/chronos/src/modules/doorlock/self.ts +++ b/apps/chronos/src/modules/doorlock/self.ts @@ -17,7 +17,7 @@ export const listSelfCards = base.doorlock.self.cards.list .handler(async ({ context }) => { const cards = await fetchCards(eq(card.userId, context.session.userId)); - return { cards }; + return { items: cards, total: cards.length }; }); export const updateSelfCardFrozen = base.doorlock.self.cards.setFrozen @@ -51,7 +51,7 @@ export const updateSelfCardFrozen = base.doorlock.self.cards.setFrozen ) ); - return { card: updatedCard }; + return updatedCard; }); export const activateVirtualCard = base.doorlock.self.cards.activate @@ -127,5 +127,5 @@ export const activateVirtualCard = base.doorlock.self.cards.activate }); } - return { log: logEntry }; + return logEntry; }); diff --git a/apps/chronos/src/modules/doorlock/stats.ts b/apps/chronos/src/modules/doorlock/stats.ts index 665fecbf..cbed46a7 100644 --- a/apps/chronos/src/modules/doorlock/stats.ts +++ b/apps/chronos/src/modules/doorlock/stats.ts @@ -120,5 +120,5 @@ export const deviceStats = base.doorlock.devices.stats }, })); - return formattedStats; + return { stats: formattedStats }; }); diff --git a/apps/chronos/src/modules/kiosk/departures.ts b/apps/chronos/src/modules/kiosk/departures.ts index f1b6a405..bd8d443d 100644 --- a/apps/chronos/src/modules/kiosk/departures.ts +++ b/apps/chronos/src/modules/kiosk/departures.ts @@ -176,5 +176,5 @@ export const departures = base.kiosk.departures.handler(async ({ input }) => { }) ); - return { departures: next }; + return { items: next, total: next.length }; }); diff --git a/apps/chronos/src/modules/kiosk/index.ts b/apps/chronos/src/modules/kiosk/index.ts index 462fb3ef..6ac8f836 100644 --- a/apps/chronos/src/modules/kiosk/index.ts +++ b/apps/chronos/src/modules/kiosk/index.ts @@ -11,11 +11,11 @@ import { requireAuthorization } from '#middleware/auth'; import { kiosk } from '#modules/kiosk/schema'; import { base } from '#orpc'; import { badRequest, conflict, notFound } from '#utils/http'; +import { someErrorCause, UNIQUE_VIOLATION } from '#utils/postgres'; const logger = getLogger(['chronos', 'kiosk']); const MACHINE_ID_CONSTRAINT = 'kiosk_machine_id_unique'; -const UNIQUE_VIOLATION = '23505'; /** * The discriminator and the config blob arrive as separate fields, so the blob @@ -36,35 +36,23 @@ function parseKioskConfig(kind: KioskKind, config: unknown) { } /** - * Drizzle wraps driver errors, so the violation is reported on the cause - * chain. `machine_id` is the kiosk table's only unique constraint, so both its - * name and the SQLSTATE (Bun exposes it as `errno`) identify the conflict. + * `machine_id` is the kiosk table's only unique constraint, so either its name + * or the SQLSTATE identifies the conflict. */ -function isMachineIdConflict(error: unknown): boolean { - let current: unknown = error; - - while (current !== null && typeof current === 'object') { - const { constraint, errno } = current as { - constraint?: unknown; - errno?: unknown; - }; - - if (constraint === MACHINE_ID_CONSTRAINT || errno === UNIQUE_VIOLATION) { - return true; - } - - current = (current as { cause?: unknown }).cause; - } - - return false; -} +const isMachineIdConflict = (error: unknown): boolean => + someErrorCause( + error, + (node) => + node.constraint === MACHINE_ID_CONSTRAINT || + node.errno === UNIQUE_VIOLATION + ); export const listKiosks = base.kiosk.list .use(requireAuthorization(permissions.kiosksManage)) .handler(async () => { const kiosks = await db.select().from(kiosk).orderBy(asc(kiosk.name)); - return { kiosks }; + return { items: kiosks, total: kiosks.length }; }); export const createKiosk = base.kiosk.create @@ -99,7 +87,7 @@ export const createKiosk = base.kiosk.create throw errors.INTERNAL({ message: 'Failed to create kiosk' }); } - return { kiosk: inserted }; + return inserted; }); export const updateKiosk = base.kiosk.update @@ -124,7 +112,7 @@ export const updateKiosk = base.kiosk.update }; if (Object.keys(changes).length === 0) { - return { kiosk: existing }; + return existing; } const [updated] = await db @@ -137,7 +125,7 @@ export const updateKiosk = base.kiosk.update throw notFound('Kiosk not found'); } - return { kiosk: updated }; + return updated; }); export const deleteKiosk = base.kiosk.delete @@ -154,5 +142,5 @@ export const deleteKiosk = base.kiosk.delete throw notFound('Kiosk not found'); } - return { kiosk: deleted }; + return { id: deleted.id }; }); diff --git a/apps/chronos/src/modules/kiosk/news.ts b/apps/chronos/src/modules/kiosk/news.ts index a0a7ea42..724b3ea4 100644 --- a/apps/chronos/src/modules/kiosk/news.ts +++ b/apps/chronos/src/modules/kiosk/news.ts @@ -59,18 +59,18 @@ export const listNews = base.kiosk.news.list.handler(async () => { item.title !== null ); - return { - announcements: titled.map((item) => ({ - body: flattenAnnouncementContent(item.body), - highlighted: item.highlighted, - id: item.id, - imageVersion: item.imageUpdatedAt?.toISOString() ?? null, - kioskIds: kioskMappings - .filter((m) => m.announcementId === item.id) - .map((m) => m.kioskId), - title: item.title, - validFrom: item.validFrom, - validUntil: item.validUntil, - })), - }; + const items = titled.map((item) => ({ + body: flattenAnnouncementContent(item.body), + highlighted: item.highlighted, + id: item.id, + imageVersion: item.imageUpdatedAt?.toISOString() ?? null, + kioskIds: kioskMappings + .filter((m) => m.announcementId === item.id) + .map((m) => m.kioskId), + title: item.title, + validFrom: item.validFrom, + validUntil: item.validUntil, + })); + + return { items, total: items.length }; }); diff --git a/apps/chronos/src/modules/kiosk/petrik-news.ts b/apps/chronos/src/modules/kiosk/petrik-news.ts index d3443f95..4ec7ff2e 100644 --- a/apps/chronos/src/modules/kiosk/petrik-news.ts +++ b/apps/chronos/src/modules/kiosk/petrik-news.ts @@ -144,14 +144,15 @@ export const petrikNews = base.kiosk.petrikNews.handler(async ({ input }) => { const feed = await resolveFeed(machine); if (!feed.enabled) { - return { items: [] }; + return { items: [], total: 0 }; } // Serve the cache without a DNS round-trip; only a miss reaches the SSRF // guard, so a warm cache never pays for the validation. const cached = feedCache.get(feed.feedUrl); if (cached && cached.expiresAt > Date.now()) { - return { items: cached.items.slice(0, feed.maxItems) }; + const items = cached.items.slice(0, feed.maxItems); + return { items, total: items.length }; } let xml: string; @@ -201,5 +202,6 @@ export const petrikNews = base.kiosk.petrikNews.handler(async ({ input }) => { items, }); - return { items: items.slice(0, feed.maxItems) }; + const capped = items.slice(0, feed.maxItems); + return { items: capped, total: capped.length }; }); diff --git a/apps/chronos/src/modules/kiosk/schema.ts b/apps/chronos/src/modules/kiosk/schema.ts index 83320d50..1af90f94 100644 --- a/apps/chronos/src/modules/kiosk/schema.ts +++ b/apps/chronos/src/modules/kiosk/schema.ts @@ -7,6 +7,7 @@ import { timestamp, uuid, } from 'drizzle-orm/pg-core'; +import type { JSONType } from 'zod'; import { timestamps } from '#database/helpers'; /** @@ -17,7 +18,7 @@ import { timestamps } from '#database/helpers'; */ export const kiosk = pgTable('kiosk', { appVersion: text('app_version'), - config: jsonb('config').notNull().default(sql`'{}'::jsonb`), + config: jsonb('config').$type().notNull().default(sql`'{}'::jsonb`), enabled: boolean('enabled').notNull().default(true), id: uuid('id').primaryKey().defaultRandom(), kind: text('kind').notNull(), diff --git a/apps/chronos/src/modules/navigator/buildings.ts b/apps/chronos/src/modules/navigator/buildings.ts index 7e9643bd..b5c2b984 100644 --- a/apps/chronos/src/modules/navigator/buildings.ts +++ b/apps/chronos/src/modules/navigator/buildings.ts @@ -15,7 +15,7 @@ export const listBuildingsRoute = base.navigator.buildings.list.handler( .from(buildingTable) .orderBy(asc(buildingTable.name)); - return { buildings }; + return { items: buildings, total: buildings.length }; } ); @@ -33,7 +33,7 @@ export const createBuildingRoute = base.navigator.buildings.create }); } - return { building }; + return building; }); export const updateBuildingRoute = base.navigator.buildings.update @@ -57,7 +57,7 @@ export const updateBuildingRoute = base.navigator.buildings.update throw notFound('Building not found'); } - return { building }; + return building; }); export const deleteBuildingRoute = base.navigator.buildings.delete @@ -75,7 +75,7 @@ export const deleteBuildingRoute = base.navigator.buildings.delete throw notFound('Building not found'); } - return { building }; + return { id }; } catch (error) { // The foreign key is what decides whether the building still has rooms: // translating its error avoids a pre-check query that would race a diff --git a/apps/chronos/src/modules/navigator/classroom-types.ts b/apps/chronos/src/modules/navigator/classroom-types.ts index 3f3bfe9f..a585b42b 100644 --- a/apps/chronos/src/modules/navigator/classroom-types.ts +++ b/apps/chronos/src/modules/navigator/classroom-types.ts @@ -15,7 +15,7 @@ export const listClassroomTypesRoute = .from(classroomTypeTable) .orderBy(asc(classroomTypeTable.name)); - return { classroom_types: classroomTypes }; + return { items: classroomTypes, total: classroomTypes.length }; }); export const createClassroomTypeRoute = base.navigator.classroomTypes.create @@ -32,7 +32,7 @@ export const createClassroomTypeRoute = base.navigator.classroomTypes.create }); } - return { classroom_type: classroomType }; + return classroomType; }); export const updateClassroomTypeRoute = base.navigator.classroomTypes.update @@ -58,7 +58,7 @@ export const updateClassroomTypeRoute = base.navigator.classroomTypes.update throw notFound('Classroom type not found'); } - return { classroom_type: classroomType }; + return classroomType; }); export const deleteClassroomTypeRoute = base.navigator.classroomTypes.delete @@ -76,7 +76,7 @@ export const deleteClassroomTypeRoute = base.navigator.classroomTypes.delete throw notFound('Classroom type not found'); } - return { classroom_type: classroomType }; + return { id }; } catch (error) { // `classroom.type_id` restricts the delete, so the FK error is what says // a classroom still uses this type. diff --git a/apps/chronos/src/modules/navigator/classrooms.ts b/apps/chronos/src/modules/navigator/classrooms.ts index dce7ec85..997be283 100644 --- a/apps/chronos/src/modules/navigator/classrooms.ts +++ b/apps/chronos/src/modules/navigator/classrooms.ts @@ -19,7 +19,7 @@ export const listClassroomsRoute = base.navigator.classrooms.list.handler( .from(classroomTable) .orderBy(asc(classroomTable.name)); - return { classrooms }; + return { items: classrooms, total: classrooms.length }; } ); @@ -37,7 +37,7 @@ export const createClassroomRoute = base.navigator.classrooms.create }); } - return { classroom }; + return classroom; }); export const updateClassroomRoute = base.navigator.classrooms.update @@ -64,7 +64,7 @@ export const updateClassroomRoute = base.navigator.classrooms.update throw notFound('Classroom not found'); } - return { classroom }; + return classroom; }); export const deleteClassroomRoute = base.navigator.classrooms.delete @@ -112,5 +112,5 @@ export const deleteClassroomRoute = base.navigator.classrooms.delete throw notFound('Classroom not found'); } - return { classroom }; + return { id }; }); diff --git a/apps/chronos/src/modules/navigator/corridors.ts b/apps/chronos/src/modules/navigator/corridors.ts index ce3a7e14..eb4c9bc2 100644 --- a/apps/chronos/src/modules/navigator/corridors.ts +++ b/apps/chronos/src/modules/navigator/corridors.ts @@ -14,7 +14,7 @@ export const listCorridorsRoute = base.navigator.corridors.list.handler( .from(navigatorCorridor) .orderBy(asc(navigatorCorridor.name)); - return { corridors }; + return { items: corridors, total: corridors.length }; } ); @@ -32,7 +32,7 @@ export const createCorridorRoute = base.navigator.corridors.create }); } - return { corridor }; + return corridor; }); export const updateCorridorRoute = base.navigator.corridors.update @@ -58,7 +58,7 @@ export const updateCorridorRoute = base.navigator.corridors.update throw notFound('Corridor not found'); } - return { corridor }; + return corridor; }); export const deleteCorridorRoute = base.navigator.corridors.delete @@ -75,5 +75,5 @@ export const deleteCorridorRoute = base.navigator.corridors.delete throw notFound('Corridor not found'); } - return { corridor }; + return { id }; }); diff --git a/apps/chronos/src/modules/navigator/lifts.ts b/apps/chronos/src/modules/navigator/lifts.ts index f0f02eed..863c75ba 100644 --- a/apps/chronos/src/modules/navigator/lifts.ts +++ b/apps/chronos/src/modules/navigator/lifts.ts @@ -13,7 +13,7 @@ export const listLiftsRoute = base.navigator.lifts.list.handler(async () => { .from(navigatorLift) .orderBy(asc(navigatorLift.name)); - return { lifts }; + return { items: lifts, total: lifts.length }; }); export const createLiftRoute = base.navigator.lifts.create @@ -28,7 +28,7 @@ export const createLiftRoute = base.navigator.lifts.create throw new ORPCError('INTERNAL', { message: 'Failed to create lift' }); } - return { lift }; + return lift; }); export const updateLiftRoute = base.navigator.lifts.update @@ -51,7 +51,7 @@ export const updateLiftRoute = base.navigator.lifts.update throw notFound('Lift not found'); } - return { lift }; + return lift; }); export const deleteLiftRoute = base.navigator.lifts.delete @@ -68,5 +68,5 @@ export const deleteLiftRoute = base.navigator.lifts.delete throw notFound('Lift not found'); } - return { lift }; + return { id }; }); diff --git a/apps/chronos/src/modules/navigator/stairs.ts b/apps/chronos/src/modules/navigator/stairs.ts index e9b96b7f..506b630f 100644 --- a/apps/chronos/src/modules/navigator/stairs.ts +++ b/apps/chronos/src/modules/navigator/stairs.ts @@ -13,7 +13,7 @@ export const listStairsRoute = base.navigator.stairs.list.handler(async () => { .from(navigatorStair) .orderBy(asc(navigatorStair.name)); - return { stairs }; + return { items: stairs, total: stairs.length }; }); export const createStairRoute = base.navigator.stairs.create @@ -28,7 +28,7 @@ export const createStairRoute = base.navigator.stairs.create throw new ORPCError('INTERNAL', { message: 'Failed to create stair' }); } - return { stair }; + return stair; }); export const updateStairRoute = base.navigator.stairs.update @@ -54,7 +54,7 @@ export const updateStairRoute = base.navigator.stairs.update throw notFound('Staircase not found'); } - return { stair }; + return stair; }); export const deleteStairRoute = base.navigator.stairs.delete @@ -71,5 +71,5 @@ export const deleteStairRoute = base.navigator.stairs.delete throw notFound('Staircase not found'); } - return { stair }; + return { id }; }); diff --git a/apps/chronos/src/modules/navigator/translations.ts b/apps/chronos/src/modules/navigator/translations.ts index 69893a04..edbafc60 100644 --- a/apps/chronos/src/modules/navigator/translations.ts +++ b/apps/chronos/src/modules/navigator/translations.ts @@ -14,7 +14,9 @@ export const listTranslationLanguagesRoute = .from(navigatorTranslation) .orderBy(asc(navigatorTranslation.lang_key)); - return { languages: rows.map((row) => row.lang_key) }; + const languages = rows.map((row) => row.lang_key); + + return { items: languages, total: languages.length }; }); export const getTranslationBundleRoute = @@ -43,7 +45,7 @@ export const listTranslationsRoute = base.navigator.translations.list asc(navigatorTranslation.lang_key) ); - return { translations }; + return { items: translations, total: translations.length }; }); export const createTranslationRoute = base.navigator.translations.create @@ -72,7 +74,7 @@ export const createTranslationRoute = base.navigator.translations.create }) .returning(); - return { translations: rows }; + return { items: rows, total: rows.length }; }); export const updateTranslationRoute = base.navigator.translations.update @@ -95,7 +97,7 @@ export const updateTranslationRoute = base.navigator.translations.update }); } - return { translation }; + return translation; }); export const deleteTranslationRoute = base.navigator.translations.delete @@ -117,5 +119,5 @@ export const deleteTranslationRoute = base.navigator.translations.delete throw notFound('Translation not found'); } - return { translation }; + return { id: key }; }); diff --git a/apps/chronos/src/modules/navigator/utils/errors.ts b/apps/chronos/src/modules/navigator/utils/errors.ts index c09099d7..4aca46c7 100644 --- a/apps/chronos/src/modules/navigator/utils/errors.ts +++ b/apps/chronos/src/modules/navigator/utils/errors.ts @@ -1,43 +1,22 @@ +import { someErrorCause } from '#utils/postgres'; + /** * Whether Postgres refused a delete because another row still references it: * SQLSTATE 23001 for a RESTRICT violation, 23503 for a foreign-key check on - * the other side. Bun surfaces the SQLSTATE as `errno` and Drizzle wraps the - * driver error in its own, so the cause chain has to be walked. + * the other side. */ -export const isReferencedRowError = (error: unknown) => { - let current: unknown = error; - while (current instanceof Error) { - const { code, errno } = current as { code?: string; errno?: string }; - if ( - errno === '23001' || - errno === '23503' || - code === '23001' || - code === '23503' - ) { - return true; - } - current = current.cause; - } - return false; -}; +export const isReferencedRowError = (error: unknown): boolean => + someErrorCause( + error, + (node) => node.errno === '23001' || node.errno === '23503' + ); /** * Whether the error (or any wrapped cause) is a Postgres integrity-constraint * violation: SQLSTATE class 23 (unique, foreign-key, not-null, check, ...). - * Bun surfaces the SQLSTATE as `errno` and Drizzle wraps the driver error, so - * the cause chain has to be walked. */ -export const isIntegrityViolation = (error: unknown): boolean => { - let current: unknown = error; - while (current instanceof Error) { - const { code, errno } = current as { code?: string; errno?: string }; - if ( - (typeof code === 'string' && code.startsWith('23')) || - (typeof errno === 'string' && errno.startsWith('23')) - ) { - return true; - } - current = current.cause; - } - return false; -}; +export const isIntegrityViolation = (error: unknown): boolean => + someErrorCause( + error, + (node) => typeof node.errno === 'string' && node.errno.startsWith('23') + ); diff --git a/apps/chronos/src/modules/navigator/utils/schemas.ts b/apps/chronos/src/modules/navigator/utils/schemas.ts index ee2df958..280f845a 100644 --- a/apps/chronos/src/modules/navigator/utils/schemas.ts +++ b/apps/chronos/src/modules/navigator/utils/schemas.ts @@ -1,4 +1,3 @@ -import z from 'zod'; import { navigatorCorridor, navigatorLift, @@ -16,97 +15,3 @@ export const corridorSelectSchema = createSelectSchema(navigatorCorridor); export const liftSelectSchema = createSelectSchema(navigatorLift); export const stairSelectSchema = createSelectSchema(navigatorStair); export const translationSelectSchema = createSelectSchema(navigatorTranslation); - -export const buildingsResponseSchema = z.object({ - data: z.object({ buildings: z.array(buildingSelectSchema) }), - success: z.literal(true), -}); - -export const buildingResponseSchema = z.object({ - data: z.object({ building: buildingSelectSchema }), - success: z.literal(true), -}); - -export const classroomTypesResponseSchema = z.object({ - data: z.object({ classroom_types: z.array(classroomTypeSelectSchema) }), - success: z.literal(true), -}); - -export const classroomTypeResponseSchema = z.object({ - data: z.object({ classroom_type: classroomTypeSelectSchema }), - success: z.literal(true), -}); - -export const classroomsResponseSchema = z.object({ - data: z.object({ classrooms: z.array(classroomSelectSchema) }), - success: z.literal(true), -}); - -export const classroomResponseSchema = z.object({ - data: z.object({ classroom: classroomSelectSchema }), - success: z.literal(true), -}); - -export const corridorsResponseSchema = z.object({ - data: z.object({ corridors: z.array(corridorSelectSchema) }), - success: z.literal(true), -}); - -export const corridorResponseSchema = z.object({ - data: z.object({ corridor: corridorSelectSchema }), - success: z.literal(true), -}); - -export const liftsResponseSchema = z.object({ - data: z.object({ lifts: z.array(liftSelectSchema) }), - success: z.literal(true), -}); - -export const liftResponseSchema = z.object({ - data: z.object({ lift: liftSelectSchema }), - success: z.literal(true), -}); - -export const stairsResponseSchema = z.object({ - data: z.object({ stairs: z.array(stairSelectSchema) }), - success: z.literal(true), -}); - -export const stairResponseSchema = z.object({ - data: z.object({ stair: stairSelectSchema }), - success: z.literal(true), -}); - -/** The whole campus in one payload, in the upstream wire shape. */ -export const graphResponseSchema = z.object({ - data: z.object({ - buildings: z.array(buildingSelectSchema), - classroom_types: z.array(classroomTypeSelectSchema), - classrooms: z.array(classroomSelectSchema), - corridors: z.array(corridorSelectSchema), - lifts: z.array(liftSelectSchema), - stairs: z.array(stairSelectSchema), - }), - success: z.literal(true), -}); - -export const translationsResponseSchema = z.object({ - data: z.object({ translations: z.array(translationSelectSchema) }), - success: z.literal(true), -}); - -export const translationResponseSchema = z.object({ - data: z.object({ translation: translationSelectSchema }), - success: z.literal(true), -}); - -/** A flat `text_key -> text` bundle for one language. */ -export const translationBundleResponseSchema = z.object({ - data: z.record(z.string(), z.string()), - success: z.literal(true), -}); - -export const languagesResponseSchema = z.object({ - data: z.object({ languages: z.array(z.string()) }), - success: z.literal(true), -}); diff --git a/apps/chronos/src/modules/navigator/utils/transfer.ts b/apps/chronos/src/modules/navigator/utils/transfer.ts index a40001c7..c0726ee5 100644 --- a/apps/chronos/src/modules/navigator/utils/transfer.ts +++ b/apps/chronos/src/modules/navigator/utils/transfer.ts @@ -85,21 +85,3 @@ export const navigatorImportSchema = navigatorTransferSchema.extend({ lifts: z.array(liftImportRowSchema), stairs: z.array(stairImportRowSchema), }); - -export const navigatorTransferResponseSchema = z.object({ - data: navigatorTransferSchema, - success: z.literal(true), -}); - -export const navigatorImportResponseSchema = z.object({ - data: z.object({ - buildings: z.number(), - classrooms: z.number(), - classroomTypes: z.number(), - corridors: z.number(), - lifts: z.number(), - stairs: z.number(), - translations: z.number(), - }), - success: z.literal(true), -}); diff --git a/apps/chronos/src/modules/news/announcements.ts b/apps/chronos/src/modules/news/announcements.ts index 9c861d86..4d8ee72d 100644 --- a/apps/chronos/src/modules/news/announcements.ts +++ b/apps/chronos/src/modules/news/announcements.ts @@ -12,7 +12,10 @@ import { announcementKioskMtm, } from '#modules/news/schema'; import { activeAnnouncementConditions } from '#modules/news/utils/announcements'; -import { validateCohortIds } from '#modules/news/utils/cohort'; +import { + cohortVisibilityCondition, + validateCohortIds, +} from '#modules/news/utils/cohort'; import { validateKioskIds } from '#modules/news/utils/kiosks'; import { announcementSelect, @@ -127,29 +130,34 @@ async function setAnnouncementTargeting( kioskIds, }: { cohortIds?: string[] | undefined; kioskIds?: string[] | undefined } ): Promise { - if (cohortIds !== undefined) { - await db - .delete(announcementCohortMtm) - .where(eq(announcementCohortMtm.announcementId, announcementId)); - - if (cohortIds.length > 0) { - await db - .insert(announcementCohortMtm) - .values(cohortIds.map((cohortId) => ({ announcementId, cohortId }))); + // Clear-then-reinsert must not be observable half-done: a failure between the + // two leaves the announcement with no targeting at all (i.e. visible to + // everyone), which is the opposite of what the caller asked for. + await db.transaction(async (tx) => { + if (cohortIds !== undefined) { + await tx + .delete(announcementCohortMtm) + .where(eq(announcementCohortMtm.announcementId, announcementId)); + + if (cohortIds.length > 0) { + await tx + .insert(announcementCohortMtm) + .values(cohortIds.map((cohortId) => ({ announcementId, cohortId }))); + } } - } - if (kioskIds !== undefined) { - await db - .delete(announcementKioskMtm) - .where(eq(announcementKioskMtm.announcementId, announcementId)); + if (kioskIds !== undefined) { + await tx + .delete(announcementKioskMtm) + .where(eq(announcementKioskMtm.announcementId, announcementId)); - if (kioskIds.length > 0) { - await db - .insert(announcementKioskMtm) - .values(kioskIds.map((kioskId) => ({ announcementId, kioskId }))); + if (kioskIds.length > 0) { + await tx + .insert(announcementKioskMtm) + .values(kioskIds.map((kioskId) => ({ announcementId, kioskId }))); + } } - } + }); } export const listAnnouncements = base.news.announcements.list @@ -176,22 +184,18 @@ export const listAnnouncements = base.news.announcements.list conditions.push(eq(announcement.kioskOnly, false)); } - // Cohort filtering: show items that are global (no rows in M2M) - // or targeted to the user's cohort. Users without a cohort only see - // global announcements. includeAll=true bypasses this filter. + // Cohort filtering: show items that are global or targeted to the viewer's + // cohort. includeAll=true bypasses this filter. if (!bypassCohortFilter) { - if (userCohortId) { - conditions.push( - sql`( - NOT EXISTS (SELECT 1 FROM announcement_cohort_mtm WHERE announcement_id = ${announcement.id}) - OR EXISTS (SELECT 1 FROM announcement_cohort_mtm WHERE announcement_id = ${announcement.id} AND cohort_id = ${userCohortId}) - )` - ); - } else { - conditions.push( - sql`NOT EXISTS (SELECT 1 FROM announcement_cohort_mtm WHERE announcement_id = ${announcement.id})` - ); - } + conditions.push( + cohortVisibilityCondition( + announcementCohortMtm, + announcementCohortMtm.announcementId, + announcement.id, + announcementCohortMtm.cohortId, + userCohortId + ) + ); } const where = conditions.length > 0 ? and(...conditions) : undefined; @@ -235,7 +239,7 @@ export const listAnnouncements = base.news.announcements.list .map((m) => m.kioskId), })); - return { data, total: totalResult[0]?.count ?? 0 }; + return { items: data, total: totalResult[0]?.count ?? 0 }; }); export const getAnnouncement = base.news.announcements.get diff --git a/apps/chronos/src/modules/news/blogs.ts b/apps/chronos/src/modules/news/blogs.ts index 8badbfb3..5f054679 100644 --- a/apps/chronos/src/modules/news/blogs.ts +++ b/apps/chronos/src/modules/news/blogs.ts @@ -61,7 +61,7 @@ export const listPublishedBlogs = base.news.blogs.list.handler( db.select({ count: count() }).from(blogPost).where(where), ]); - return { data: items, total: totalResult[0]?.count ?? 0 }; + return { items, total: totalResult[0]?.count ?? 0 }; } ); @@ -119,7 +119,7 @@ export const listDrafts = base.news.blogs.drafts db.select({ count: count() }).from(blogPost), ]); - return { data: items, total: totalResult[0]?.count ?? 0 }; + return { items, total: totalResult[0]?.count ?? 0 }; }); export const getBlogById = base.news.blogs.getById diff --git a/apps/chronos/src/modules/news/schema.ts b/apps/chronos/src/modules/news/schema.ts index b8f4f2e9..e1efa616 100644 --- a/apps/chronos/src/modules/news/schema.ts +++ b/apps/chronos/src/modules/news/schema.ts @@ -11,6 +11,7 @@ import { timestamp, uuid, } from 'drizzle-orm/pg-core'; +import type { JSONType } from 'zod'; import { timestamps } from '#database/helpers'; import { user } from '#database/schema/authentication'; import { kiosk } from '#modules/kiosk/schema'; @@ -22,7 +23,7 @@ export const announcement = pgTable( authorId: uuid('author_id') .notNull() .references(() => user.id, { onDelete: 'set null' }), - content: jsonb('content').notNull(), + content: jsonb('content').$type().notNull(), /** Shown as a full-screen takeover on the TV kiosks. */ highlighted: boolean('highlighted').notNull().default(false), id: uuid('id').primaryKey().defaultRandom(), @@ -56,7 +57,7 @@ export const systemMessage = pgTable('system_message', { authorId: uuid('author_id') .notNull() .references(() => user.id, { onDelete: 'set null' }), - content: jsonb('content').notNull(), + content: jsonb('content').$type().notNull(), id: uuid('id').primaryKey().defaultRandom(), title: text('title').notNull(), validFrom: timestamp('valid_from').notNull(), @@ -68,7 +69,7 @@ export const blogPost = pgTable('blog_post', { authorId: uuid('author_id') .notNull() .references(() => user.id, { onDelete: 'set null' }), - content: jsonb('content').notNull(), + content: jsonb('content').$type().notNull(), id: uuid('id').primaryKey().defaultRandom(), publishedAt: timestamp('published_at'), slug: text('slug').notNull().unique(), diff --git a/apps/chronos/src/modules/news/system-messages.ts b/apps/chronos/src/modules/news/system-messages.ts index bf690665..22e25281 100644 --- a/apps/chronos/src/modules/news/system-messages.ts +++ b/apps/chronos/src/modules/news/system-messages.ts @@ -6,7 +6,10 @@ import { db } from '#database'; import { user } from '#database/schema/authentication'; import { requireAuthorization } from '#middleware/auth'; import { systemMessage, systemMessageCohortMtm } from '#modules/news/schema'; -import { validateCohortIds } from '#modules/news/utils/cohort'; +import { + cohortVisibilityCondition, + validateCohortIds, +} from '#modules/news/utils/cohort'; import { authorSelect } from '#modules/news/utils/shared'; import { base } from '#orpc'; import { badRequest, notFound } from '#utils/http'; @@ -29,21 +32,16 @@ export const listSystemMessages = base.news.systemMessages.list.handler( } // A message with no cohort rows is global; a targeted one is only visible - // to its cohorts. A user without a cohort therefore sees global messages - // only — without this branch the predicate is skipped entirely and they - // would receive messages targeted at every other cohort. - if (userCohortId) { - conditions.push( - sql`( - NOT EXISTS (SELECT 1 FROM system_message_cohort_mtm WHERE system_message_id = ${systemMessage.id}) - OR EXISTS (SELECT 1 FROM system_message_cohort_mtm WHERE system_message_id = ${systemMessage.id} AND cohort_id = ${userCohortId}) - )` - ); - } else { - conditions.push( - sql`NOT EXISTS (SELECT 1 FROM system_message_cohort_mtm WHERE system_message_id = ${systemMessage.id})` - ); - } + // to its cohorts, so a viewer without a cohort sees global ones only. + conditions.push( + cohortVisibilityCondition( + systemMessageCohortMtm, + systemMessageCohortMtm.systemMessageId, + systemMessage.id, + systemMessageCohortMtm.cohortId, + userCohortId + ) + ); const where = conditions.length > 0 ? and(...conditions) : undefined; @@ -85,7 +83,7 @@ export const listSystemMessages = base.news.systemMessages.list.handler( .map((m) => m.cohortId), })); - return { data, total: totalResult[0]?.count ?? 0 }; + return { items: data, total: totalResult[0]?.count ?? 0 }; } ); @@ -134,30 +132,37 @@ export const createSystemMessage = base.news.systemMessages.create await validateCohortIds(body.cohortIds); } - const [created] = await db - .insert(systemMessage) - .values({ - authorId, - content: body.content, - title: body.title, - validFrom: body.validFrom, - validUntil: body.validUntil, - }) - .returning(); - if (!created) { - throw new ORPCError('INTERNAL', { - message: 'Failed to create system message', - }); - } + // Row and cohort links are one unit: a failure between them leaves a + // message with no targeting, i.e. visible to every cohort. + const created = await db.transaction(async (tx) => { + const [row] = await tx + .insert(systemMessage) + .values({ + authorId, + content: body.content, + title: body.title, + validFrom: body.validFrom, + validUntil: body.validUntil, + }) + .returning(); - if (body.cohortIds && body.cohortIds.length > 0) { - await db.insert(systemMessageCohortMtm).values( - body.cohortIds.map((cohortId) => ({ - cohortId, - systemMessageId: created.id, - })) - ); - } + if (!row) { + throw new ORPCError('INTERNAL', { + message: 'Failed to create system message', + }); + } + + if (body.cohortIds && body.cohortIds.length > 0) { + await tx.insert(systemMessageCohortMtm).values( + body.cohortIds.map((cohortId) => ({ + cohortId, + systemMessageId: row.id, + })) + ); + } + + return row; + }); dispatchPendingNotification(created.id, 'system_message', { cohortIds: body.cohortIds ?? [], @@ -218,18 +223,22 @@ export const updateSystemMessage = base.news.systemMessages.update } if (body.cohortIds !== undefined) { - await db - .delete(systemMessageCohortMtm) - .where(eq(systemMessageCohortMtm.systemMessageId, id)); - - if (body.cohortIds.length > 0) { - await db.insert(systemMessageCohortMtm).values( - body.cohortIds.map((cohortId) => ({ - cohortId, - systemMessageId: id, - })) - ); - } + // Clear-then-reinsert must not be observable half-done: a failure between + // the two leaves the message with no targeting, i.e. visible to everyone. + await db.transaction(async (tx) => { + await tx + .delete(systemMessageCohortMtm) + .where(eq(systemMessageCohortMtm.systemMessageId, id)); + + if (body.cohortIds && body.cohortIds.length > 0) { + await tx.insert(systemMessageCohortMtm).values( + body.cohortIds.map((cohortId) => ({ + cohortId, + systemMessageId: id, + })) + ); + } + }); } const cohortIds = diff --git a/apps/chronos/src/modules/news/utils/cohort.ts b/apps/chronos/src/modules/news/utils/cohort.ts index a15bf48d..7c17a406 100644 --- a/apps/chronos/src/modules/news/utils/cohort.ts +++ b/apps/chronos/src/modules/news/utils/cohort.ts @@ -1,4 +1,5 @@ -import { sql } from 'drizzle-orm'; +import { type SQL, sql } from 'drizzle-orm'; +import type { AnyPgColumn, PgTable } from 'drizzle-orm/pg-core'; import { db } from '#database'; import { cohort } from '#modules/timetable/schema'; import { badRequest } from '#utils/http'; @@ -15,3 +16,34 @@ export const validateCohortIds = async (cohortIds: string[]) => { throw badRequest(`Invalid cohort IDs: ${invalid.join(', ')}`); } }; + +/** + * The condition restricting a cohort-targeted row to what a viewer may see. + * + * A row with no cohort links is global; a targeted row only matches its own + * cohorts. A viewer without a cohort therefore sees global rows only — the + * `else` branch is the whole point, and omitting it leaks every targeted row to + * every cohort-less viewer. + * + * Shared rather than written per caller: announcements and system messages each + * had their own copy, and they had already drifted into disagreeing about that + * branch. + */ +export const cohortVisibilityCondition = ( + linkTable: PgTable, + ownerColumn: AnyPgColumn, + ownerId: AnyPgColumn | SQL, + cohortColumn: AnyPgColumn, + viewerCohortId: string | null | undefined +): SQL => { + const untargeted = sql`NOT EXISTS (SELECT 1 FROM ${linkTable} WHERE ${ownerColumn} = ${ownerId})`; + + if (!viewerCohortId) { + return untargeted; + } + + return sql`( + ${untargeted} + OR EXISTS (SELECT 1 FROM ${linkTable} WHERE ${ownerColumn} = ${ownerId} AND ${cohortColumn} = ${viewerCohortId}) + )`; +}; diff --git a/apps/chronos/src/modules/news/utils/shared.ts b/apps/chronos/src/modules/news/utils/shared.ts index e30f844c..646c6ba4 100644 --- a/apps/chronos/src/modules/news/utils/shared.ts +++ b/apps/chronos/src/modules/news/utils/shared.ts @@ -1,7 +1,5 @@ -import z from 'zod'; import { user } from '#database/schema/authentication'; -import { announcement, systemMessage } from '#modules/news/schema'; -import { createSelectSchema } from '#utils/zod'; +import { announcement } from '#modules/news/schema'; /** Shared `author` projection used by announcement/system-message list & detail. */ export const authorSelect = { @@ -32,64 +30,5 @@ export const announcementSelect = { validUntil: announcement.validUntil, }; -export const authorSchema = z.object({ - id: z.string(), - image: z.string().nullable(), - name: z.string(), -}); - export const resolveTitle = (title: string | null | undefined): string => title && title.length > 0 ? title : 'Untitled'; - -const announcementSelectSchema = createSelectSchema(announcement); -const systemMessageSelectSchema = createSelectSchema(systemMessage); - -export const announcementItemSchema = announcementSelectSchema.extend({ - author: authorSchema.nullable().optional(), - cohortIds: z.array(z.string()), - kioskIds: z.array(z.string()), -}); - -export const systemMessageItemSchema = systemMessageSelectSchema.extend({ - author: authorSchema.nullable().optional(), - cohortIds: z.array(z.string()), -}); - -export const announcementListResponseSchema = z.object({ - data: z.array(announcementItemSchema), - success: z.literal(true), - total: z.number(), -}); - -export const systemMessageListResponseSchema = z.object({ - data: z.array(systemMessageItemSchema), - success: z.literal(true), - total: z.number(), -}); - -export const announcementDetailResponseSchema = z.object({ - data: announcementItemSchema, - success: z.literal(true), -}); - -export const systemMessageDetailResponseSchema = z.object({ - data: systemMessageItemSchema, - success: z.literal(true), -}); - -export const announcementBaseDetailResponseSchema = z.object({ - data: announcementSelectSchema.extend({ - cohortIds: z.array(z.string()), - kioskIds: z.array(z.string()), - }), - success: z.literal(true), -}); - -export const systemMessageBaseDetailResponseSchema = z.object({ - data: systemMessageSelectSchema.extend({ cohortIds: z.array(z.string()) }), - success: z.literal(true), -}); - -export const successResponseSchema = z.object({ - success: z.literal(true), -}); diff --git a/apps/chronos/src/modules/notifications/notifications.ts b/apps/chronos/src/modules/notifications/notifications.ts index ba7ca719..c26406e4 100644 --- a/apps/chronos/src/modules/notifications/notifications.ts +++ b/apps/chronos/src/modules/notifications/notifications.ts @@ -234,20 +234,19 @@ export const registerFcmToken = base.notifications.fcmTokens.register const { token: fcmTokenValue } = input; - const [existing] = await db - .select() - .from(fcmToken) - .where( - and(eq(fcmToken.userId, userId), eq(fcmToken.token, fcmTokenValue)) - ); - - if (!existing) { - await db.insert(fcmToken).values({ + // Upsert on the (user_id, token) unique index so two concurrent + // registrations of the same token cannot both insert. + await db + .insert(fcmToken) + .values({ deviceInfo: input.deviceInfo ?? null, token: fcmTokenValue, userId, + }) + .onConflictDoUpdate({ + set: { deviceInfo: input.deviceInfo ?? null }, + target: [fcmToken.userId, fcmToken.token], }); - } return { ok: true as const }; }); diff --git a/apps/chronos/src/modules/notifications/schema.ts b/apps/chronos/src/modules/notifications/schema.ts index ad93e623..5ec2d956 100644 --- a/apps/chronos/src/modules/notifications/schema.ts +++ b/apps/chronos/src/modules/notifications/schema.ts @@ -5,6 +5,7 @@ import { pgTable, text, timestamp, + uniqueIndex, uuid, } from 'drizzle-orm/pg-core'; import { timestamps } from '#database/helpers'; @@ -59,14 +60,18 @@ export const notification = pgTable( ] ); -export const fcmToken = pgTable('fcm_token', { - createdAt: timestamp('created_at').notNull().defaultNow(), - deviceInfo: text('device_info'), - id: uuid('id').primaryKey().defaultRandom(), - token: text('token').notNull(), - userId: uuid('user_id') - .notNull() - .references(() => user.id, { onDelete: 'cascade' }), -}); +export const fcmToken = pgTable( + 'fcm_token', + { + createdAt: timestamp('created_at').notNull().defaultNow(), + deviceInfo: text('device_info'), + id: uuid('id').primaryKey().defaultRandom(), + token: text('token').notNull(), + userId: uuid('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + }, + (t) => [uniqueIndex('fcm_token_user_id_token_unique').on(t.userId, t.token)] +); export const notificationsSchema = { fcmToken, notification, userPreferences }; diff --git a/apps/chronos/src/modules/roles/index.ts b/apps/chronos/src/modules/roles/index.ts index e8033c00..3a6bee4b 100644 --- a/apps/chronos/src/modules/roles/index.ts +++ b/apps/chronos/src/modules/roles/index.ts @@ -1,13 +1,17 @@ import { permissions } from '@filcdev/api/permissions'; -import { SQL } from 'bun'; import { requireAuthorization } from '#middleware/auth'; import { base } from '#orpc'; import { rbac } from '#utils/authorization'; import { badRequest, conflict, notFound } from '#utils/http'; +import { isUniqueViolation } from '#utils/postgres'; export const listPermissions = base.roles.permissions .use(requireAuthorization(permissions.rolesRead)) - .handler(() => ({ permissions: rbac.getAllPermissions() })); + .handler(() => { + const allPermissions = rbac.getAllPermissions(); + + return { items: allPermissions, total: allPermissions.length }; + }); export const listRoles = base.roles.list .use(requireAuthorization(permissions.rolesRead)) @@ -19,7 +23,7 @@ export const listRoles = base.roles.list name, })); - return { roles }; + return { items: roles, total: roles.length }; }); export const createRole = base.roles.create @@ -35,8 +39,10 @@ export const createRole = base.roles.create try { await rbac.createRole(name, rolePermissions); } catch (error) { - // Postgres unique-violation from a concurrent create. - if (error instanceof SQL.PostgresError && error.code === '23505') { + // Postgres unique-violation from a concurrent create. Bun puts the + // SQLSTATE on `errno` — its `code` is its own string, so comparing that + // to a SQLSTATE never matched and the race answered 500. + if (isUniqueViolation(error)) { throw conflict(`Role "${name}" already exists`); } throw error; diff --git a/apps/chronos/src/modules/timetable/_notifications.ts b/apps/chronos/src/modules/timetable/_notifications.ts index 4f80cd8f..a8500cc4 100644 --- a/apps/chronos/src/modules/timetable/_notifications.ts +++ b/apps/chronos/src/modules/timetable/_notifications.ts @@ -1,7 +1,7 @@ import { env } from '#utils/environment'; import { - resolveAudienceForCohortReselection, resolveAudienceForLessonChange, + resolveAudienceForUser, } from '#utils/notifications/engine'; import { isHungarianLocale } from '#utils/notifications/locale'; import { @@ -230,7 +230,7 @@ export const timetableNotifications: readonly NotificationHandler[] = [ : 'Cohort Reselection Required', }; }, - getAudience: (payload) => resolveAudienceForCohortReselection(payload), + getAudience: (payload) => resolveAudienceForUser(payload), getDelay: () => 0, type: 'cohort_reselection_required', }, diff --git a/apps/chronos/src/modules/timetable/cohort.ts b/apps/chronos/src/modules/timetable/cohort.ts index 4c03713f..97ea9750 100644 --- a/apps/chronos/src/modules/timetable/cohort.ts +++ b/apps/chronos/src/modules/timetable/cohort.ts @@ -15,5 +15,5 @@ export const getCohortsForTimetable = const cohorts = cohortRows.map((r) => r.cohort); - return cohorts; + return { items: cohorts, total: cohorts.length }; }); diff --git a/apps/chronos/src/modules/timetable/export.ts b/apps/chronos/src/modules/timetable/export.ts index ca7b94d5..218949b1 100644 --- a/apps/chronos/src/modules/timetable/export.ts +++ b/apps/chronos/src/modules/timetable/export.ts @@ -1,6 +1,6 @@ import type { DateRangeQueryInput } from '@filcdev/api/domains/timetable/export'; import { permissions } from '@filcdev/api/permissions'; -import { and, desc, eq, gte, inArray, lte, type SQL } from 'drizzle-orm'; +import { desc, eq, gte, inArray, lte, type SQL } from 'drizzle-orm'; import { db } from '#database'; import { requireAuthentication, requireAuthorization } from '#middleware/auth'; import { @@ -18,6 +18,7 @@ import { teacher, } from '#modules/timetable/schema'; import { base } from '#orpc'; +import { andAll } from '#utils/drizzle'; const buildDateFilters = ( filters: SQL[], @@ -69,10 +70,7 @@ export const exportSubstitutionsRoute = base.timetable.substitutions.export substitution.date as unknown as SQL ); - let combined: SQL | undefined; - for (const clause of filters) { - combined = combined ? and(combined, clause) : clause; - } + const combined = andAll(filters); const substitutions = await db .select({ @@ -184,10 +182,7 @@ export const exportMovedLessonsRoute = base.timetable.movedLessons.export movedLesson.date as unknown as SQL ); - let combined: SQL | undefined; - for (const clause of filters) { - combined = combined ? and(combined, clause) : clause; - } + const combined = andAll(filters); const movedLessons = await db .select({ diff --git a/apps/chronos/src/modules/timetable/groups.ts b/apps/chronos/src/modules/timetable/groups.ts index 6a91433a..e754e469 100644 --- a/apps/chronos/src/modules/timetable/groups.ts +++ b/apps/chronos/src/modules/timetable/groups.ts @@ -67,7 +67,7 @@ export const getGroupsForCohort = base.timetable.groups.getForCohort.handler( : null, selected: selected.has(row.id), })); - return data; + return { items: data, total: data.length }; } ); diff --git a/apps/chronos/src/modules/timetable/index.ts b/apps/chronos/src/modules/timetable/index.ts index 50c2fc17..74c4256b 100644 --- a/apps/chronos/src/modules/timetable/index.ts +++ b/apps/chronos/src/modules/timetable/index.ts @@ -16,9 +16,9 @@ import { } from '#modules/timetable/schema'; import { getActiveTimetableId } from '#modules/timetable/utils/active'; import { cleanupOrphanedCohorts } from '#modules/timetable/utils/cleanup'; -import { dateToYYYYMMDD } from '#modules/timetable/utils/date'; import { remapSubstitutionLessonsToTimetable } from '#modules/timetable/utils/remap-substitutions'; import { base } from '#orpc'; +import { toDateOnlyString } from '#utils/date'; import { badRequest, notFound } from '#utils/http'; import { dispatchImmediateNotification } from '#utils/notifications/engine'; @@ -26,7 +26,7 @@ export const getAllTimetables = base.timetable.timetables.list.handler( async () => { const timetables = await db.select().from(timetable); - return timetables; + return { items: timetables, total: timetables.length }; } ); @@ -52,7 +52,7 @@ export const getLatestValidTimetable = export const getAllValidTimetables = base.timetable.timetables.valid.handler( async () => { - const today = dateToYYYYMMDD(new Date()); + const today = toDateOnlyString(new Date()); const timetables = await db .select() @@ -64,7 +64,7 @@ export const getAllValidTimetables = base.timetable.timetables.valid.handler( ) ); - return timetables; + return { items: timetables, total: timetables.length }; } ); diff --git a/apps/chronos/src/modules/timetable/lesson.ts b/apps/chronos/src/modules/timetable/lesson.ts index 522b69ef..99081f53 100644 --- a/apps/chronos/src/modules/timetable/lesson.ts +++ b/apps/chronos/src/modules/timetable/lesson.ts @@ -12,20 +12,17 @@ import { db } from '#database'; import { classroom, cohort, - cohortGroup, - dayDefinition, lesson, lessonCohortMTM, - period, subject, substitutionLessonMTM, teacher, - weekDefinition, } from '#modules/timetable/schema'; import { getActiveTimetableId, getTimetableIdForDate, } from '#modules/timetable/utils/active'; +import { enrichLessons } from '#modules/timetable/utils/enrich-lessons'; import { getWeekdayInBudapest, isMatchingWeekday, @@ -35,205 +32,6 @@ import { notFound } from '#utils/http'; const logger = getLogger(['chronos', 'lesson']); -async function enrichLessons(lessons: (typeof lesson.$inferSelect)[]) { - if (lessons.length === 0) { - return []; - } - - const subjectIds = Array.from( - new Set(lessons.map((l) => l.subjectId)) - ).filter((id): id is string => id != null); - const dayIds = Array.from(new Set(lessons.map((l) => l.dayDefinitionId))); - const periodIds = Array.from(new Set(lessons.map((l) => l.periodId))); - const teacherIds = Array.from( - new Set( - lessons.flatMap((l) => (Array.isArray(l.teacherIds) ? l.teacherIds : [])) - ) - ); - const classroomIds = Array.from( - new Set( - lessons.flatMap((l) => - Array.isArray(l.classroomIds) ? l.classroomIds : [] - ) - ) - ); - const groupIds = Array.from( - new Set( - lessons.flatMap((l) => (Array.isArray(l.groupsIds) ? l.groupsIds : [])) - ) - ); - - const weekDefinitionIds = Array.from( - new Set(lessons.map((l) => l.weeksDefinitionId)) - ); - - const lessonIds = lessons.map((l) => l.id); - - const [ - subjects, - days, - periods, - teachers, - classrooms, - cohortRows, - groupRows, - weekDefinitions, - ] = await Promise.all([ - subjectIds.length - ? db.select().from(subject).where(inArray(subject.id, subjectIds)) - : Promise.resolve([] as (typeof subject.$inferSelect)[]), - db.select().from(dayDefinition).where(inArray(dayDefinition.id, dayIds)), - db.select().from(period).where(inArray(period.id, periodIds)), - teacherIds.length - ? db.select().from(teacher).where(inArray(teacher.id, teacherIds)) - : Promise.resolve([] as (typeof teacher.$inferSelect)[]), - classroomIds.length - ? db.select().from(classroom).where(inArray(classroom.id, classroomIds)) - : Promise.resolve([] as (typeof classroom.$inferSelect)[]), - lessonIds.length - ? db - .select({ - cohortId: cohort.id, - cohortName: cohort.name, - cohortShort: cohort.short, - lessonId: lessonCohortMTM.lessonId, - }) - .from(lessonCohortMTM) - .innerJoin(cohort, eq(lessonCohortMTM.cohortId, cohort.id)) - .where(inArray(lessonCohortMTM.lessonId, lessonIds)) - : Promise.resolve([] as never[]), - groupIds.length - ? db - .select({ - divisionTag: cohortGroup.divisionTag, - entireClass: cohortGroup.entireClass, - id: cohortGroup.id, - name: cohortGroup.name, - }) - .from(cohortGroup) - .where(inArray(cohortGroup.id, groupIds)) - : Promise.resolve([] as never[]), - - db - .select() - .from(weekDefinition) - .where(inArray(weekDefinition.id, weekDefinitionIds)), - ]); - - const subjMap = new Map(subjects.map((s) => [s.id, s] as const)); - const dayMap = new Map(days.map((d) => [d.id, d] as const)); - const periodMap = new Map(periods.map((p) => [p.id, p] as const)); - const teacherMap = new Map(teachers.map((t) => [t.id, t] as const)); - const classroomMap = new Map(classrooms.map((cr) => [cr.id, cr] as const)); - const groupMap = new Map( - groupRows.map( - (g) => - [ - g.id, - { - divisionTag: g.divisionTag, - entireClass: g.entireClass, - id: g.id, - name: g.name, - }, - ] as const - ) - ); - const weekDefinitionMap = new Map( - weekDefinitions.map((week) => [week.id, week] as const) - ); - const cohortMap = new Map< - string, - { id: string; name: string; short: string }[] - >(lessonIds.map((id) => [id, []])); - for (const row of cohortRows) { - const list = cohortMap.get(row.lessonId); - if (list) { - list.push({ - id: row.cohortId, - name: row.cohortName, - short: row.cohortShort, - }); - } - } - - return lessons.map((l) => { - const tIds = (Array.isArray(l.teacherIds) ? l.teacherIds : []) as string[]; - const cIds = ( - Array.isArray(l.classroomIds) ? l.classroomIds : [] - ) as string[]; - - return { - classrooms: cIds - .map((id) => classroomMap.get(id)) - .filter(Boolean) - .map((cr) => ({ - id: (cr as (typeof classrooms)[number]).id, - name: (cr as (typeof classrooms)[number]).name, - short: (cr as (typeof classrooms)[number]).short, - })), - cohorts: (cohortMap.get(l.id) ?? []).map((c) => ({ - id: c.id, - name: c.name, - short: c.short, - })), - day: (() => { - const d = dayMap.get(l.dayDefinitionId); - return d; - })(), - groups: (Array.isArray(l.groupsIds) ? l.groupsIds : []) - .map((id) => groupMap.get(id)) - .filter(Boolean) - .map((g) => ({ - divisionTag: (g as (typeof groupRows)[number]).divisionTag, - entireClass: (g as (typeof groupRows)[number]).entireClass, - id: (g as (typeof groupRows)[number]).id, - name: (g as (typeof groupRows)[number]).name, - })), - groupsIds: (Array.isArray(l.groupsIds) ? l.groupsIds : []) as string[], - id: l.id, - period: (() => { - const p = periodMap.get(l.periodId); - return p - ? { - endTime: String(p.endTime), - id: p.id, - period: p.period, - startTime: String(p.startTime), - } - : null; - })(), - periodsPerWeek: l.periodsPerWeek, - subject: (() => { - const s = subjMap.get(l.subjectId ?? ''); - return s ? { id: s.id, name: s.name, short: s.short } : null; - })(), - teachers: tIds - .map((id) => teacherMap.get(id)) - .filter(Boolean) - .map((t) => ({ - id: (t as (typeof teachers)[number]).id, - name: `${(t as (typeof teachers)[number]).firstName} ${(t as (typeof teachers)[number]).lastName}`, - short: (t as (typeof teachers)[number]).short, - })), - termDefinitionId: l.termDefinitionId, - weekDefinition: (() => { - const week = weekDefinitionMap.get(l.weeksDefinitionId); - - return week - ? { - id: week.id, - name: week.name, - short: week.short, - weeks: Array.isArray(week.weeks) ? week.weeks : [], - } - : null; - })(), - weeksDefinitionId: l.weeksDefinitionId, - }; - }); -} - export const getLessonsForCohort = base.timetable.lessons.getForCohort.handler( async ({ input }) => { const { cohortId, timetableId } = input; @@ -252,7 +50,7 @@ export const getLessonsForCohort = base.timetable.lessons.getForCohort.handler( // No active timetable: yield no lessons rather than every timetable. if (!effectiveTimetableId) { - return []; + return { items: [], total: 0 }; } const whereClause = and( @@ -269,12 +67,12 @@ export const getLessonsForCohort = base.timetable.lessons.getForCohort.handler( const lessons = lessonRows.map((r) => r.lesson); if (lessons.length === 0) { - return []; + return { items: [], total: 0 }; } const enriched = await enrichLessons(lessons); - return enriched; + return { items: enriched, total: enriched.length }; } ); @@ -296,7 +94,7 @@ export const getLessonsForTeacher = // No active timetable: yield no lessons rather than every timetable. if (!effectiveTimetableId) { - return []; + return { items: [], total: 0 }; } const whereClause = and( @@ -307,12 +105,12 @@ export const getLessonsForTeacher = const lessons = await db.select().from(lesson).where(whereClause); if (lessons.length === 0) { - return []; + return { items: [], total: 0 }; } const enriched = await enrichLessons(lessons); - return enriched; + return { items: enriched, total: enriched.length }; }); type CandidateLessonEntry = { @@ -529,14 +327,14 @@ export const getLessonsForTeachers = const existingTeacherIds = existingTeachers.map((t) => t.id); if (existingTeacherIds.length === 0) { - return []; + return { items: [], total: 0 }; } const timetableId = await getActiveTimetableId(); // No active timetable: yield no lessons rather than every timetable. if (!timetableId) { - return []; + return { items: [], total: 0 }; } const lessons = await db @@ -574,7 +372,7 @@ export const getLessonsForTeachers = teacherId, })); - return data; + return { items: data, total: data.length }; }); export const getSubstitutionCandidates = @@ -729,7 +527,7 @@ export const getLessonsForRoom = base.timetable.lessons.getForRoom.handler( // No active timetable: yield no lessons rather than every timetable. if (!effectiveTimetableId) { - return []; + return { items: [], total: 0 }; } const whereClause = and( @@ -740,12 +538,12 @@ export const getLessonsForRoom = base.timetable.lessons.getForRoom.handler( const lessons = await db.select().from(lesson).where(whereClause); if (lessons.length === 0) { - return []; + return { items: [], total: 0 }; } const enriched = await enrichLessons(lessons); - return enriched; + return { items: enriched, total: enriched.length }; } ); @@ -793,7 +591,7 @@ export const getLessonForId = base.timetable.lessons.getForId.handler( export const getSubjects = base.timetable.subjects.handler(async () => { try { const subjects = await db.select().from(subject); - return subjects; + return { items: subjects, total: subjects.length }; } catch (error) { logger.error('Error while fetching subjects', { error }); throw new ORPCError('INTERNAL', { message: 'Failed to fetch subjects' }); diff --git a/apps/chronos/src/modules/timetable/moved-lesson.ts b/apps/chronos/src/modules/timetable/moved-lesson.ts index f7a2ae50..fd1045f7 100644 --- a/apps/chronos/src/modules/timetable/moved-lesson.ts +++ b/apps/chronos/src/modules/timetable/moved-lesson.ts @@ -1,3 +1,4 @@ +import type { Lesson } from '@filcdev/api/domains/timetable/lesson'; import { permissions } from '@filcdev/api/permissions'; import { ORPCError } from '@orpc/server'; import { and, eq, gte, inArray, sql } from 'drizzle-orm'; @@ -14,8 +15,8 @@ import { } from '#modules/timetable/schema'; import { getActiveTimetableId } from '#modules/timetable/utils/active'; import { - type EnrichedLesson, enrichLessons, + loadLessonsByIds, } from '#modules/timetable/utils/enrich-lessons'; import { base } from '#orpc'; import { badRequest, notFound } from '#utils/http'; @@ -188,7 +189,7 @@ type MovedLessonRow = { // is retired, so this must not be scoped to the active timetable. async function attachEnrichedLessons(rows: MovedLessonRow[]) { const allLessonIds = Array.from(new Set(rows.flatMap((r) => r.lessons))); - const enriched = await enrichLessons(allLessonIds); + const enriched = await enrichLessons(await loadLessonsByIds(allLessonIds)); const lessonMap = new Map(enriched.map((l) => [l.id, l])); return rows.map((r) => ({ @@ -196,7 +197,7 @@ async function attachEnrichedLessons(rows: MovedLessonRow[]) { dayDefinition: r.dayDefinition, lessons: r.lessons .map((id) => lessonMap.get(id)) - .filter((l): l is EnrichedLesson => l !== undefined), + .filter((l): l is Lesson => l !== undefined), movedLesson: r.movedLesson, period: r.period, })); @@ -225,7 +226,8 @@ export const getAllMovedLessons = base.timetable.movedLessons.list.handler( ) .groupBy(movedLesson.id, period.id, dayDefinition.id, classroom.id); - return await attachEnrichedLessons(movedLessons); + const items = await attachEnrichedLessons(movedLessons); + return { items, total: items.length }; } ); @@ -256,7 +258,8 @@ export const getRelevantMovedLessons = .where(gte(movedLesson.date, today)) .groupBy(movedLesson.id, period.id, dayDefinition.id, classroom.id); - return await attachEnrichedLessons(movedLessons); + const items = await attachEnrichedLessons(movedLessons); + return { items, total: items.length }; }); export const getMovedLessonsForCohort = @@ -287,7 +290,8 @@ export const getMovedLessonsForCohort = .where(eq(lessonCohortMTM.cohortId, cohortId)) .groupBy(movedLesson.id, period.id, dayDefinition.id, classroom.id); - return await attachEnrichedLessons(movedLessons); + const items = await attachEnrichedLessons(movedLessons); + return { items, total: items.length }; }); export const getRelevantMovedLessonsForCohort = @@ -326,7 +330,8 @@ export const getRelevantMovedLessonsForCohort = ) .groupBy(movedLesson.id, period.id, dayDefinition.id, classroom.id); - return await attachEnrichedLessons(movedLessons); + const items = await attachEnrichedLessons(movedLessons); + return { items, total: items.length }; }); export const createMovedLesson = base.timetable.movedLessons.create diff --git a/apps/chronos/src/modules/timetable/period.ts b/apps/chronos/src/modules/timetable/period.ts index ce01ae05..27a33b50 100644 --- a/apps/chronos/src/modules/timetable/period.ts +++ b/apps/chronos/src/modules/timetable/period.ts @@ -19,7 +19,7 @@ export const getPeriodsForTimetable = base.timetable.periods.getAll.handler( const ids = usedPeriodIds.map((r) => r.periodId); if (ids.length === 0) { - return []; + return { items: [], total: 0 }; } periods = await db @@ -38,6 +38,6 @@ export const getPeriodsForTimetable = base.timetable.periods.getAll.handler( startTime: String(p.startTime), })); - return data; + return { items: data, total: data.length }; } ); diff --git a/apps/chronos/src/modules/timetable/room.ts b/apps/chronos/src/modules/timetable/room.ts index 1f763572..fb5fdf43 100644 --- a/apps/chronos/src/modules/timetable/room.ts +++ b/apps/chronos/src/modules/timetable/room.ts @@ -11,7 +11,7 @@ import { base } from '#orpc'; export const getClassrooms = base.timetable.classrooms.getAll.handler( async () => { const classrooms = await db.select().from(classroom); - return classrooms; + return { items: classrooms, total: classrooms.length }; } ); @@ -74,5 +74,5 @@ export const getAvailableClassrooms = ) ); - return availableClassrooms; + return { items: availableClassrooms, total: availableClassrooms.length }; }); diff --git a/apps/chronos/src/modules/timetable/substitution.ts b/apps/chronos/src/modules/timetable/substitution.ts index ffeb84a2..f2edae5b 100644 --- a/apps/chronos/src/modules/timetable/substitution.ts +++ b/apps/chronos/src/modules/timetable/substitution.ts @@ -1,3 +1,4 @@ +import type { Lesson } from '@filcdev/api/domains/timetable/lesson'; import { permissions } from '@filcdev/api/permissions'; import { ORPCError } from '@orpc/server'; import { and, asc, eq, gte, inArray, isNull, ne, sql } from 'drizzle-orm'; @@ -18,8 +19,8 @@ import { } from '#modules/timetable/schema'; import { getActiveTimetableId } from '#modules/timetable/utils/active'; import { - type EnrichedLesson, enrichLessons, + loadLessonsByIds, } from '#modules/timetable/utils/enrich-lessons'; import { getWeekdayInBudapest, @@ -360,14 +361,16 @@ export const getAllSubstitutions = base.timetable.substitutions.list.handler( // Enrich every linked lesson. A substitution keeps pointing at the lesson // it was created for, even after that lesson's timetable is retired, so // this must not be scoped to the active timetable. - const enrichedLessons = await enrichLessons(allLessonIds); + const enrichedLessons = await enrichLessons( + await loadLessonsByIds(allLessonIds) + ); const lessonMap = new Map(enrichedLessons.map((l) => [l.id, l])); // Map lessons back to substitutions const result = substitutions.map((s) => ({ lessons: s.lessonIds .map((id) => lessonMap.get(id)) - .filter((l): l is EnrichedLesson => l !== undefined), + .filter((l): l is Lesson => l !== undefined), substitution: s.substitution, teacher: s.teacher, })); @@ -391,7 +394,7 @@ export const getAllSubstitutions = base.timetable.substitutions.list.handler( return minPeriod(a) - minPeriod(b); }); - return result; + return { items: result, total: result.length }; } ); @@ -421,7 +424,7 @@ export const getRelevantSubstitutions = .groupBy(substitution.id, teacher.id) .orderBy(asc(substitution.date), asc(sql`MIN(${period.period})`)); - return substitutions; + return { items: substitutions, total: substitutions.length }; }); export const getRelevantSubstitutionsForCohort = diff --git a/apps/chronos/src/modules/timetable/teacher.ts b/apps/chronos/src/modules/timetable/teacher.ts index 71d06b6a..e3d616ff 100644 --- a/apps/chronos/src/modules/timetable/teacher.ts +++ b/apps/chronos/src/modules/timetable/teacher.ts @@ -23,7 +23,7 @@ export const getTeachers = base.timetable.teachers.getAll.handler(async () => { }) .from(teacher); - return teachers; + return { items: teachers, total: teachers.length }; }); /** @@ -85,7 +85,7 @@ export const listTeachersAdmin = base.timetable.teachers.list userId: row.userId, })); - return data; + return { items: data, total: data.length }; }); /** Manually set a teacher's email and/or linked user. */ diff --git a/apps/chronos/src/modules/timetable/utils/active.ts b/apps/chronos/src/modules/timetable/utils/active.ts index d4a75de2..ca6a9092 100644 --- a/apps/chronos/src/modules/timetable/utils/active.ts +++ b/apps/chronos/src/modules/timetable/utils/active.ts @@ -1,12 +1,12 @@ import { and, desc, gte, isNull, lte, or } from 'drizzle-orm'; import { db } from '#database'; import { timetable } from '#modules/timetable/schema'; -import { dateToYYYYMMDD } from './date'; +import { toDateOnlyString } from '#utils/date'; export async function getTimetableIdForDate( date: Date ): Promise { - const targetDate = dateToYYYYMMDD(date); + const targetDate = toDateOnlyString(date); const [active] = await db .select({ id: timetable.id }) diff --git a/apps/chronos/src/modules/timetable/utils/date.ts b/apps/chronos/src/modules/timetable/utils/date.ts deleted file mode 100644 index 22e0a6f8..00000000 --- a/apps/chronos/src/modules/timetable/utils/date.ts +++ /dev/null @@ -1,6 +0,0 @@ -export const dateToYYYYMMDD = (date: Date): string => - date.toLocaleDateString('en-CA', { - day: '2-digit', - month: '2-digit', - year: 'numeric', - }); diff --git a/apps/chronos/src/modules/timetable/utils/enrich-lessons.ts b/apps/chronos/src/modules/timetable/utils/enrich-lessons.ts index f7e0ff9f..13b31094 100644 --- a/apps/chronos/src/modules/timetable/utils/enrich-lessons.ts +++ b/apps/chronos/src/modules/timetable/utils/enrich-lessons.ts @@ -1,127 +1,65 @@ -import { and, eq, inArray } from 'drizzle-orm'; -import z from 'zod'; +import type { Lesson } from '@filcdev/api/domains/timetable/lesson'; +import { eq, inArray } from 'drizzle-orm'; import { db } from '#database'; import { classroom, cohort, + cohortGroup, dayDefinition, lesson, lessonCohortMTM, period, subject, teacher, + weekDefinition, } from '#modules/timetable/schema'; -import { createSelectSchema } from '#utils/zod'; +import { distinctIds } from '#utils/collections'; -// Enriched lesson shape shared by the substitution and moved-lesson endpoints. -export const enrichedLessonSchema = z.object({ - classrooms: z.array( - z.object({ id: z.string(), name: z.string(), short: z.string() }) - ), - cohorts: z.array(z.string()), - day: createSelectSchema(dayDefinition).optional(), - id: z.string(), - period: z - .object({ - endTime: z.string(), - id: z.string(), - period: z.number(), - startTime: z.string(), - }) - .nullable(), - periodsPerWeek: z.number(), - subject: z - .object({ id: z.string(), name: z.string(), short: z.string() }) - .nullable(), - teachers: z.array( - z.object({ id: z.string(), name: z.string(), short: z.string() }) - ), - termDefinitionId: z.string().nullable(), - weeksDefinitionId: z.string(), -}); - -export type EnrichedLesson = z.infer; - -// Enrich a batch of lessons with subject, cohorts, day, period, classrooms and -// teachers. Returns only lessons that still exist; callers map the results back -// onto their own lesson-id lists. -// -// `timetableId` optionally scopes enrichment to a single timetable. When it is -// `undefined` (callers that predate timetable scoping), no timetable filter is -// applied. When it is a string or an explicit `null`, lessons are restricted to -// that timetable — a `null` id matches nothing (lesson.timetableId is NOT NULL), -// so a gap in active timetables returns no lessons rather than leaking retired -// ones. -export async function enrichLessons( - lessonIds: string[], - timetableId?: string | null -): Promise { - if (lessonIds.length === 0) { - return []; +/** Load lesson rows by id. Ids that no longer exist are simply absent. */ +export function loadLessonsByIds( + ids: string[] +): Promise<(typeof lesson.$inferSelect)[]> { + if (ids.length === 0) { + return Promise.resolve([]); } - // A null timetable id means "no active timetable": match nothing instead of - // falling back to every (including retired) timetable. - if (timetableId === null) { - return []; - } - - const lessons = await db - .select() - .from(lesson) - .where( - timetableId === undefined - ? inArray(lesson.id, lessonIds) - : and( - inArray(lesson.id, lessonIds), - eq(lesson.timetableId, timetableId) - ) - ); + return db.select().from(lesson).where(inArray(lesson.id, ids)); +} +// Enrich lesson rows with subject, cohorts, day, period, classrooms, teachers, +// groups and week definition. Callers load the rows first (see +// `loadLessonsByIds`) and map the results back onto their own id lists; ids +// that no longer resolve are dropped here. +export async function enrichLessons( + lessons: (typeof lesson.$inferSelect)[] +): Promise { if (lessons.length === 0) { return []; } - const subjectIds = Array.from( - new Set(lessons.map((l) => l.subjectId)) - ).filter((id): id is string => id != null); - const dayIds = Array.from(new Set(lessons.map((l) => l.dayDefinitionId))); - const periodIds = Array.from(new Set(lessons.map((l) => l.periodId))); - const teacherIds = Array.from( - new Set( - lessons.flatMap((l) => (Array.isArray(l.teacherIds) ? l.teacherIds : [])) - ) - ); - const classroomIds = Array.from( - new Set( - lessons.flatMap((l) => - Array.isArray(l.classroomIds) ? l.classroomIds : [] - ) - ) - ); - - // Get lesson-cohort relationships - const lessonCohorts = await db - .select({ - cohortId: lessonCohortMTM.cohortId, - cohortName: cohort.name, - lessonId: lessonCohortMTM.lessonId, - }) - .from(lessonCohortMTM) - .innerJoin(cohort, eq(lessonCohortMTM.cohortId, cohort.id)) - .where(inArray(lessonCohortMTM.lessonId, lessonIds)); + const subjectIds = distinctIds(lessons, (l) => l.subjectId); + const dayIds = distinctIds(lessons, (l) => l.dayDefinitionId); + const periodIds = distinctIds(lessons, (l) => l.periodId); + const teacherIds = distinctIds(lessons, (l) => l.teacherIds); + const classroomIds = distinctIds(lessons, (l) => l.classroomIds); + const groupIds = distinctIds(lessons, (l) => l.groupsIds); + const weekDefinitionIds = distinctIds(lessons, (l) => l.weeksDefinitionId); - // Create a map of lesson ID to cohort names - const lessonCohortMap = new Map(); - for (const lc of lessonCohorts) { - if (!lessonCohortMap.has(lc.lessonId)) { - lessonCohortMap.set(lc.lessonId, []); - } - lessonCohortMap.get(lc.lessonId)?.push(lc.cohortName); - } + const lessonIds = lessons.map((l) => l.id); - const [subjects, days, periods, teachers, classrooms] = await Promise.all([ - db.select().from(subject).where(inArray(subject.id, subjectIds)), + const [ + subjects, + days, + periods, + teachers, + classrooms, + cohortRows, + groupRows, + weekDefinitions, + ] = await Promise.all([ + subjectIds.length + ? db.select().from(subject).where(inArray(subject.id, subjectIds)) + : Promise.resolve([] as (typeof subject.$inferSelect)[]), db.select().from(dayDefinition).where(inArray(dayDefinition.id, dayIds)), db.select().from(period).where(inArray(period.id, periodIds)), teacherIds.length @@ -130,6 +68,34 @@ export async function enrichLessons( classroomIds.length ? db.select().from(classroom).where(inArray(classroom.id, classroomIds)) : Promise.resolve([] as (typeof classroom.$inferSelect)[]), + lessonIds.length + ? db + .select({ + cohortId: cohort.id, + cohortName: cohort.name, + cohortShort: cohort.short, + lessonId: lessonCohortMTM.lessonId, + }) + .from(lessonCohortMTM) + .innerJoin(cohort, eq(lessonCohortMTM.cohortId, cohort.id)) + .where(inArray(lessonCohortMTM.lessonId, lessonIds)) + : Promise.resolve([] as never[]), + groupIds.length + ? db + .select({ + divisionTag: cohortGroup.divisionTag, + entireClass: cohortGroup.entireClass, + id: cohortGroup.id, + name: cohortGroup.name, + }) + .from(cohortGroup) + .where(inArray(cohortGroup.id, groupIds)) + : Promise.resolve([] as never[]), + + db + .select() + .from(weekDefinition) + .where(inArray(weekDefinition.id, weekDefinitionIds)), ]); const subjMap = new Map(subjects.map((s) => [s.id, s] as const)); @@ -137,13 +103,43 @@ export async function enrichLessons( const periodMap = new Map(periods.map((p) => [p.id, p] as const)); const teacherMap = new Map(teachers.map((t) => [t.id, t] as const)); const classroomMap = new Map(classrooms.map((cr) => [cr.id, cr] as const)); + const groupMap = new Map( + groupRows.map( + (g) => + [ + g.id, + { + divisionTag: g.divisionTag, + entireClass: g.entireClass, + id: g.id, + name: g.name, + }, + ] as const + ) + ); + const weekDefinitionMap = new Map( + weekDefinitions.map((week) => [week.id, week] as const) + ); + const cohortMap = new Map< + string, + { id: string; name: string; short: string }[] + >(lessonIds.map((id) => [id, []])); + for (const row of cohortRows) { + const list = cohortMap.get(row.lessonId); + if (list) { + list.push({ + id: row.cohortId, + name: row.cohortName, + short: row.cohortShort, + }); + } + } return lessons.map((l) => { const tIds = (Array.isArray(l.teacherIds) ? l.teacherIds : []) as string[]; const cIds = ( Array.isArray(l.classroomIds) ? l.classroomIds : [] ) as string[]; - const cohortNames = lessonCohortMap.get(l.id) || []; return { classrooms: cIds @@ -154,8 +150,25 @@ export async function enrichLessons( name: (cr as (typeof classrooms)[number]).name, short: (cr as (typeof classrooms)[number]).short, })), - cohorts: cohortNames, - day: dayMap.get(l.dayDefinitionId), + cohorts: (cohortMap.get(l.id) ?? []).map((c) => ({ + id: c.id, + name: c.name, + short: c.short, + })), + day: (() => { + const d = dayMap.get(l.dayDefinitionId); + return d; + })(), + groups: (Array.isArray(l.groupsIds) ? l.groupsIds : []) + .map((id) => groupMap.get(id)) + .filter(Boolean) + .map((g) => ({ + divisionTag: (g as (typeof groupRows)[number]).divisionTag, + entireClass: (g as (typeof groupRows)[number]).entireClass, + id: (g as (typeof groupRows)[number]).id, + name: (g as (typeof groupRows)[number]).name, + })), + groupsIds: (Array.isArray(l.groupsIds) ? l.groupsIds : []) as string[], id: l.id, period: (() => { const p = periodMap.get(l.periodId); @@ -182,6 +195,18 @@ export async function enrichLessons( short: (t as (typeof teachers)[number]).short, })), termDefinitionId: l.termDefinitionId, + weekDefinition: (() => { + const week = weekDefinitionMap.get(l.weeksDefinitionId); + + return week + ? { + id: week.id, + name: week.name, + short: week.short, + weeks: Array.isArray(week.weeks) ? week.weeks : [], + } + : null; + })(), weeksDefinitionId: l.weeksDefinitionId, }; }); diff --git a/apps/chronos/src/modules/users/index.ts b/apps/chronos/src/modules/users/index.ts index 5af786ef..d2320b10 100644 --- a/apps/chronos/src/modules/users/index.ts +++ b/apps/chronos/src/modules/users/index.ts @@ -43,7 +43,7 @@ export const listUsers = base.users.list .from(user) .where(whereClause); - return { total: countResult?.count ?? 0, users }; + return { items: users, total: countResult?.count ?? 0 }; }); export const updateUser = base.users.update diff --git a/apps/chronos/src/modules/wifi/admin.ts b/apps/chronos/src/modules/wifi/admin.ts index 6fc72efe..10a85dd9 100644 --- a/apps/chronos/src/modules/wifi/admin.ts +++ b/apps/chronos/src/modules/wifi/admin.ts @@ -3,6 +3,7 @@ import { ORPCError } from '@orpc/server'; import { and, asc, + count, desc, eq, getTableColumns, @@ -95,16 +96,22 @@ export const listWifiUsers = base.wifi.users.list .use(requireAuthorization(permissions.wifiRead)) .handler(async ({ input }) => { const { limit, offset, search } = input; - const users = await db - .select(userColumns) - .from(wifiUser) - .leftJoin(user, eq(wifiUser.createdBy, user.id)) - .where(search ? ilike(wifiUser.username, `%${search}%`) : undefined) - .orderBy(asc(wifiUser.createdAt)) - .limit(limit) - .offset(offset); - - return { users }; + const where = search ? ilike(wifiUser.username, `%${search}%`) : undefined; + // `total` is the unpaged count: this endpoint pages, so the page length + // would leave a client unable to work out how many pages there are. + const [users, totalResult] = await Promise.all([ + db + .select(userColumns) + .from(wifiUser) + .leftJoin(user, eq(wifiUser.createdBy, user.id)) + .where(where) + .orderBy(asc(wifiUser.createdAt)) + .limit(limit) + .offset(offset), + db.select({ count: count() }).from(wifiUser).where(where), + ]); + + return { items: users, total: totalResult[0]?.count ?? 0 }; }); export const createWifiUser = base.wifi.users.create @@ -147,7 +154,7 @@ export const createWifiUser = base.wifi.users.create throw notFound('WiFi user not found'); } - return { user: row }; + return row; }); export const updateWifiUser = base.wifi.users.update @@ -201,7 +208,7 @@ export const updateWifiUser = base.wifi.users.update throw notFound('WiFi user not found'); } - return { user: row }; + return row; }); export const deleteWifiUser = base.wifi.users.delete @@ -235,20 +242,23 @@ export const listWifiDevices = base.wifi.devices.list .use(requireAuthorization(permissions.wifiRead)) .handler(async ({ input }) => { const { limit, offset, search, wifiUserId } = input; - const devices = await db - .select() - .from(wifiDevice) - .where( - and( - search ? ilike(wifiDevice.macAddress, `%${search}%`) : undefined, - ownerFilter(wifiUserId) - ) - ) - .orderBy(desc(wifiDevice.updatedAt)) - .limit(limit) - .offset(offset); - - return { devices }; + const where = and( + search ? ilike(wifiDevice.macAddress, `%${search}%`) : undefined, + ownerFilter(wifiUserId) + ); + // Unpaged count, like every other paged list. + const [devices, totalResult] = await Promise.all([ + db + .select() + .from(wifiDevice) + .where(where) + .orderBy(desc(wifiDevice.updatedAt)) + .limit(limit) + .offset(offset), + db.select({ count: count() }).from(wifiDevice).where(where), + ]); + + return { items: devices, total: totalResult[0]?.count ?? 0 }; }); export const createWifiDevice = base.wifi.devices.create @@ -263,7 +273,7 @@ export const createWifiDevice = base.wifi.devices.create throw notFound('WiFi device not found'); } - return { device }; + return device; }); export const updateWifiDevice = base.wifi.devices.update @@ -284,7 +294,7 @@ export const updateWifiDevice = base.wifi.devices.update throw notFound('WiFi device not found'); } - return { device }; + return device; }); export const deleteWifiDevice = base.wifi.devices.delete @@ -305,9 +315,10 @@ export const deleteWifiDevice = base.wifi.devices.delete export const listWifiNas = base.wifi.nas.list .use(requireWifiEnabled) .use(requireAuthorization(permissions.wifiRead)) - .handler(async () => ({ - nas: await db.select().from(wifiNas).orderBy(asc(wifiNas.ipAddress)), - })); + .handler(async () => { + const nas = await db.select().from(wifiNas).orderBy(asc(wifiNas.ipAddress)); + return { items: nas, total: nas.length }; + }); export const createWifiNas = base.wifi.nas.create .use(requireWifiEnabled) @@ -321,7 +332,7 @@ export const createWifiNas = base.wifi.nas.create throw notFound('WiFi NAS not found'); } - return { nas }; + return nas; }); export const updateWifiNas = base.wifi.nas.update @@ -342,7 +353,7 @@ export const updateWifiNas = base.wifi.nas.update throw notFound('WiFi NAS not found'); } - return { nas }; + return nas; }); export const deleteWifiNas = base.wifi.nas.delete @@ -364,24 +375,24 @@ export const listWifiSpeedProfiles = base.wifi.speedProfiles.list .use(requireWifiEnabled) .use(requireAuthorization(permissions.wifiRead)) .handler(async () => { - const profiles = await db - .select() - .from(wifiSpeedProfile) - .orderBy(asc(wifiSpeedProfile.name)); - - return { - speedProfiles: profiles.map((profile) => - serializeSpeedProfile( - { - downloadSpeedMbps: profile.downloadSpeedMbps, - id: profile.id, - name: profile.name, - uploadSpeedMbps: profile.uploadSpeedMbps, - }, - profile.isWlanDefault - ) - ), - }; + const profiles = ( + await db + .select() + .from(wifiSpeedProfile) + .orderBy(asc(wifiSpeedProfile.name)) + ).map((profile) => + serializeSpeedProfile( + { + downloadSpeedMbps: profile.downloadSpeedMbps, + id: profile.id, + name: profile.name, + uploadSpeedMbps: profile.uploadSpeedMbps, + }, + profile.isWlanDefault + ) + ); + + return { items: profiles, total: profiles.length }; }); export const createWifiSpeedProfile = base.wifi.speedProfiles.create @@ -398,7 +409,7 @@ export const createWifiSpeedProfile = base.wifi.speedProfiles.create uploadSpeedMbps: profile.uploadSpeedMbps ?? null, }); - return { profile: serializeSpeedProfile(profile) }; + return serializeSpeedProfile(profile); }); export const updateWifiSpeedProfile = base.wifi.speedProfiles.update @@ -433,17 +444,19 @@ export const updateWifiSpeedProfile = base.wifi.speedProfiles.update .where(eq(wifiSpeedProfile.id, profile.id)) .returning({ isWlanDefault: wifiSpeedProfile.isWlanDefault }); - return { - profile: serializeSpeedProfile(profile, updated?.isWlanDefault ?? false), - }; + return serializeSpeedProfile(profile, updated?.isWlanDefault ?? false); }); export const deleteWifiSpeedProfile = base.wifi.speedProfiles.delete .use(requireWifiEnabled) .use(requireAuthorization(permissions.wifiWrite)) .handler(async ({ input }) => { - await configured().deleteSpeedProfile(input.id); + // Delete the local row before the controller: the local table is the + // source of truth that Radius reads, and the sync job reconciles a stale + // controller profile, whereas a local row pointing at a deleted profile is + // not recoverable. await db.delete(wifiSpeedProfile).where(eq(wifiSpeedProfile.id, input.id)); + await configured().deleteSpeedProfile(input.id); return { id: input.id }; }); @@ -451,12 +464,13 @@ export const deleteWifiSpeedProfile = base.wifi.speedProfiles.delete export const listWifiRoleProfiles = base.wifi.roleSpeedProfiles.list .use(requireWifiEnabled) .use(requireAuthorization(permissions.wifiRead)) - .handler(async () => ({ - roleSpeedProfiles: await db + .handler(async () => { + const roleSpeedProfiles = await db .select() .from(wifiRoleSpeedProfile) - .orderBy(desc(wifiRoleSpeedProfile.priority)), - })); + .orderBy(desc(wifiRoleSpeedProfile.priority)); + return { items: roleSpeedProfiles, total: roleSpeedProfiles.length }; + }); export const createWifiRoleProfile = base.wifi.roleSpeedProfiles.create .use(requireWifiEnabled) @@ -470,7 +484,7 @@ export const createWifiRoleProfile = base.wifi.roleSpeedProfiles.create throw notFound('WiFi role mapping not found'); } - return { mapping }; + return mapping; }); export const updateWifiRoleProfile = base.wifi.roleSpeedProfiles.update @@ -487,7 +501,7 @@ export const updateWifiRoleProfile = base.wifi.roleSpeedProfiles.update throw notFound('WiFi role mapping not found'); } - return { mapping }; + return mapping; }); export const deleteWifiRoleProfile = base.wifi.roleSpeedProfiles.delete @@ -510,51 +524,52 @@ export const listWifiAuthLogs = base.wifi.authLogs.list .use(requireAuthorization(permissions.wifiRead)) .handler(async ({ input }) => { const { limit, offset, search, failureReason, result } = input; - const logs = await db - .select({ - deviceNickname: wifiDevice.nickname, - deviceReportedHostname: wifiDevice.reportedHostname, - failureReason: wifiAuthLog.failureReason, - id: wifiAuthLog.id, - macAddress: wifiAuthLog.macAddress, - nasComment: wifiNas.comment, - nasIpAddress: wifiAuthLog.nasIpAddress, - nasMacAddress: wifiAuthLog.nasMacAddress, - result: wifiAuthLog.result, - timestamp: wifiAuthLog.timestamp, - userComment: wifiUser.comment, - username: wifiAuthLog.username, - wifiUserId: wifiAuthLog.wifiUserId, - }) - .from(wifiAuthLog) - .leftJoin( - wifiNas, - or( - eq(wifiAuthLog.nasIpAddress, wifiNas.ipAddress), - eq(wifiAuthLog.nasMacAddress, wifiNas.macAddress) - ) - ) - .leftJoin(wifiDevice, eq(wifiAuthLog.macAddress, wifiDevice.macAddress)) - .leftJoin(wifiUser, eq(wifiAuthLog.username, wifiUser.username)) - .where( - and( - search - ? or( - ilike(wifiAuthLog.username, `%${search}%`), - ilike(wifiAuthLog.macAddress, `%${search}%`), - ilike(wifiAuthLog.nasIpAddress, `%${search}%`), - ilike(wifiAuthLog.failureReason, `%${search}%`) - ) - : undefined, - failureReason - ? eq(wifiAuthLog.failureReason, failureReason) - : undefined, - result === undefined ? undefined : eq(wifiAuthLog.result, result) + const where = and( + search + ? or( + ilike(wifiAuthLog.username, `%${search}%`), + ilike(wifiAuthLog.macAddress, `%${search}%`), + ilike(wifiAuthLog.nasIpAddress, `%${search}%`), + ilike(wifiAuthLog.failureReason, `%${search}%`) + ) + : undefined, + failureReason ? eq(wifiAuthLog.failureReason, failureReason) : undefined, + result === undefined ? undefined : eq(wifiAuthLog.result, result) + ); + // Unpaged count, like every other paged list. + const [logs, totalResult] = await Promise.all([ + db + .select({ + deviceNickname: wifiDevice.nickname, + deviceReportedHostname: wifiDevice.reportedHostname, + failureReason: wifiAuthLog.failureReason, + id: wifiAuthLog.id, + macAddress: wifiAuthLog.macAddress, + nasComment: wifiNas.comment, + nasIpAddress: wifiAuthLog.nasIpAddress, + nasMacAddress: wifiAuthLog.nasMacAddress, + result: wifiAuthLog.result, + timestamp: wifiAuthLog.timestamp, + userComment: wifiUser.comment, + username: wifiAuthLog.username, + wifiUserId: wifiAuthLog.wifiUserId, + }) + .from(wifiAuthLog) + .leftJoin( + wifiNas, + or( + eq(wifiAuthLog.nasIpAddress, wifiNas.ipAddress), + eq(wifiAuthLog.nasMacAddress, wifiNas.macAddress) + ) ) - ) - .orderBy(desc(wifiAuthLog.timestamp)) - .limit(limit) - .offset(offset); - - return { logs }; + .leftJoin(wifiDevice, eq(wifiAuthLog.macAddress, wifiDevice.macAddress)) + .leftJoin(wifiUser, eq(wifiAuthLog.username, wifiUser.username)) + .where(where) + .orderBy(desc(wifiAuthLog.timestamp)) + .limit(limit) + .offset(offset), + db.select({ count: count() }).from(wifiAuthLog).where(where), + ]); + + return { items: logs, total: totalResult[0]?.count ?? 0 }; }); diff --git a/apps/chronos/src/modules/wifi/self.ts b/apps/chronos/src/modules/wifi/self.ts index da6e97ff..8a6451cd 100644 --- a/apps/chronos/src/modules/wifi/self.ts +++ b/apps/chronos/src/modules/wifi/self.ts @@ -177,12 +177,10 @@ export const createSelfWifi = base.wifi.self.create .where(eq(wifiDevice.wifiUserId, account.id)); return { - wifi: { - banned: account.banned, - devices, - speedLimit, - username: account.username, - }, + banned: account.banned, + devices, + speedLimit, + username: account.username, }; }); @@ -205,18 +203,16 @@ export const getSelfWifi = base.wifi.self.get const speed = await resolveEffectiveSpeedProfileDetails(account); return { - wifi: { - banned: account.banned, - devices, - speedLimit: { - downloadSpeedMbps: speed.downloadSpeedMbps, - roleName: speed.roleName, - source: speed.source, - speedProfileId: speed.speedProfileId, - uploadSpeedMbps: speed.uploadSpeedMbps, - }, - username: account.username, + banned: account.banned, + devices, + speedLimit: { + downloadSpeedMbps: speed.downloadSpeedMbps, + roleName: speed.roleName, + source: speed.source, + speedProfileId: speed.speedProfileId, + uploadSpeedMbps: speed.uploadSpeedMbps, }, + username: account.username, }; }); @@ -243,7 +239,7 @@ export const updateSelfWifiDevice = base.wifi.self.updateDevice throw notFound('WiFi device not found'); } - return { device }; + return device; }); export const updateSelfWifiPassword = base.wifi.self.changePassword diff --git a/apps/chronos/src/modules/wifi/stats.ts b/apps/chronos/src/modules/wifi/stats.ts index 55fc7f37..c36b6af3 100644 --- a/apps/chronos/src/modules/wifi/stats.ts +++ b/apps/chronos/src/modules/wifi/stats.ts @@ -39,13 +39,15 @@ export const wifiStats = base.wifi.stats.overview .orderBy(day); return { - activeDevices: Number(activeDevices[0]?.count ?? 0), - authSeries: rows.map((row) => ({ - accepted: Number(row.accepted), - date: row.date, - rejected: Number(row.rejected), - })), - totalDevices: Number(devices[0]?.count ?? 0), - totalUsers: Number(users[0]?.count ?? 0), + stats: { + activeDevices: Number(activeDevices[0]?.count ?? 0), + authSeries: rows.map((row) => ({ + accepted: Number(row.accepted), + date: row.date, + rejected: Number(row.rejected), + })), + totalDevices: Number(devices[0]?.count ?? 0), + totalUsers: Number(users[0]?.count ?? 0), + }, }; }); diff --git a/apps/chronos/src/utils/collections.ts b/apps/chronos/src/utils/collections.ts new file mode 100644 index 00000000..87736b7b --- /dev/null +++ b/apps/chronos/src/utils/collections.ts @@ -0,0 +1,31 @@ +/** + * The distinct ids of one column across a batch of rows, in first-seen order. + * + * A column that holds an array (a lesson's teacher/classroom ids) contributes + * its members; a null/undefined column contributes nothing. Used to turn a page + * of rows into the id list one batched query needs. + */ +export const distinctIds = ( + rows: readonly T[], + pick: (row: T) => string | readonly string[] | null | undefined +): string[] => { + const ids = new Set(); + + for (const row of rows) { + const value = pick(row); + + if (value == null) { + continue; + } + + if (typeof value === 'string') { + ids.add(value); + } else { + for (const id of value) { + ids.add(id); + } + } + } + + return [...ids]; +}; diff --git a/apps/chronos/src/utils/date.ts b/apps/chronos/src/utils/date.ts new file mode 100644 index 00000000..416f5fdc --- /dev/null +++ b/apps/chronos/src/utils/date.ts @@ -0,0 +1,13 @@ +/** + * A `Date` as `YYYY-MM-DD` in the server's local timezone. + * + * Used wherever a `date` column is compared against "today": the value must be + * built from local components, because a UTC-formatted date would name the + * previous day for every hour after local midnight in a positive-offset zone. + */ +export const toDateOnlyString = (date: Date): string => + date.toLocaleDateString('en-CA', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }); diff --git a/apps/chronos/src/utils/drizzle.ts b/apps/chronos/src/utils/drizzle.ts new file mode 100644 index 00000000..48f42562 --- /dev/null +++ b/apps/chronos/src/utils/drizzle.ts @@ -0,0 +1,15 @@ +import { and, type SQL } from 'drizzle-orm'; + +/** + * Combines a list of conditions with `AND`, returning `undefined` for an empty + * list so it can be handed straight to `.where()` (which then applies no + * filter). Drizzle's `and()` needs at least the accumulator plus a clause, so + * folding a variable-length list otherwise has to special-case the first one. + */ +export const andAll = ( + clauses: readonly SQL[] +): SQL | undefined => + clauses.reduce | undefined>( + (combined, clause) => (combined ? and(combined, clause) : clause), + undefined + ); diff --git a/apps/chronos/src/utils/notifications/engine.ts b/apps/chronos/src/utils/notifications/engine.ts index efd597cf..11c3ffbd 100644 --- a/apps/chronos/src/utils/notifications/engine.ts +++ b/apps/chronos/src/utils/notifications/engine.ts @@ -118,70 +118,41 @@ export async function resolveAudienceForBlogPost(): Promise { return enrichUsersWithLanguage(users); } -export async function resolveAudienceForDoorlock( +/** + * The audience for a notification addressed to one user: that user, with their + * stored language. Used by the types that notify a single person (a door-lock + * card holder, a cohort that has to be reselected). + */ +export async function resolveAudienceForUser( payload: unknown ): Promise { - const p = payload as { userId: string }; - const userResult = await db - .select({ - cohortId: userTable.cohortId, - email: userTable.email, - id: userTable.id, - }) - .from(userTable) - .where(eq(userTable.id, p.userId)) - .limit(1); - - const u = userResult[0]; - if (!u) { - return []; - } - - const [prefs] = await db - .select() - .from(userPreferences) - .where(eq(userPreferences.userId, u.id)) - .limit(1); - - return [ - { - cohortId: u.cohortId, - email: u.email, - id: u.id, - language: prefs?.language ?? 'hu', - }, - ]; -} + const { userId } = payload as { userId: string }; -export async function resolveAudienceForCohortReselection( - payload: unknown -): Promise { - const p = payload as { userId: string }; - const [targetUser] = await db + const [target] = await db .select({ cohortId: userTable.cohortId, email: userTable.email, id: userTable.id, }) .from(userTable) - .where(eq(userTable.id, p.userId)) + .where(eq(userTable.id, userId)) .limit(1); - if (!targetUser) { + if (!target) { return []; } const [prefs] = await db - .select() + .select({ language: userPreferences.language }) .from(userPreferences) - .where(eq(userPreferences.userId, targetUser.id)) + .where(eq(userPreferences.userId, target.id)) .limit(1); return [ { - cohortId: targetUser.cohortId, - email: targetUser.email, - id: targetUser.id, + cohortId: target.cohortId, + email: target.email, + id: target.id, language: prefs?.language ?? 'hu', }, ]; diff --git a/apps/chronos/src/utils/notifications/initialize.ts b/apps/chronos/src/utils/notifications/initialize.ts index c549ba48..ea51f32a 100644 --- a/apps/chronos/src/utils/notifications/initialize.ts +++ b/apps/chronos/src/utils/notifications/initialize.ts @@ -12,7 +12,7 @@ const logger = getLogger(['chronos', 'notifications', 'initialize']); * live with the module that raises it; this only wires the handlers the * modules declared into the engine. */ -export function initializeNotificationEngine(): void { +export async function initializeNotificationEngine(): Promise { logger.info('Initializing notification engine'); let count = 0; @@ -23,7 +23,7 @@ export function initializeNotificationEngine(): void { } } - initializeFcm(); + await initializeFcm(); logger.info(`Notification engine initialized with ${count} handlers`); } diff --git a/apps/chronos/src/utils/notifications/providers/fcm.ts b/apps/chronos/src/utils/notifications/providers/fcm.ts index e6a826cc..9dddd08a 100644 --- a/apps/chronos/src/utils/notifications/providers/fcm.ts +++ b/apps/chronos/src/utils/notifications/providers/fcm.ts @@ -12,25 +12,27 @@ const logger = getLogger(['chronos', 'notifications', 'fcm']); let fcmMessaging: FirebaseMessaging | null = null; -export function initializeFcm(): boolean { +export async function initializeFcm(): Promise { if (!env.fcmCredentials) { logger.warn('FCM credentials not configured, push notifications disabled'); return false; } try { - // eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports - const admin = require('firebase-admin'); + // Imported dynamically so firebase-admin is only loaded when FCM + // credentials are configured (a static import would pull it in always). + const { cert, getApps, initializeApp } = await import('firebase-admin'); + const { getMessaging } = await import('firebase-admin/messaging'); const credentials = JSON.parse(env.fcmCredentials); - if (admin.apps.length === 0) { - admin.initializeApp({ - credential: admin.credential.cert(credentials), + if (getApps().length === 0) { + initializeApp({ + credential: cert(credentials), projectId: env.fcmProjectId, }); } - fcmMessaging = admin.messaging(); + fcmMessaging = getMessaging(); logger.info('FCM initialized successfully'); return true; } catch (error) { diff --git a/apps/chronos/src/utils/notifications/substitution-teacher.ts b/apps/chronos/src/utils/notifications/substitution-teacher.ts index fef44273..2644bc42 100644 --- a/apps/chronos/src/utils/notifications/substitution-teacher.ts +++ b/apps/chronos/src/utils/notifications/substitution-teacher.ts @@ -11,6 +11,7 @@ import { subject, teacher, } from '#modules/timetable/schema'; +import { isHungarianLocale } from './locale'; import type { AudienceUser, NotificationContent } from './types'; const TRAILING_NEWLINES = /\n+$/; @@ -206,7 +207,7 @@ export function buildSubstitutionTeacherContent( payload: SubstitutionTeacherPayload, locale: string ): NotificationContent { - const hu = locale.toLowerCase().startsWith('hu'); + const hu = isHungarianLocale(locale); const { date, lessons } = payload; const label = { diff --git a/apps/chronos/src/utils/postgres.ts b/apps/chronos/src/utils/postgres.ts new file mode 100644 index 00000000..83423149 --- /dev/null +++ b/apps/chronos/src/utils/postgres.ts @@ -0,0 +1,45 @@ +/** + * The fields a Postgres driver error carries, and where its wrappers keep the + * original. Bun's `SQL.PostgresError` is the source; Drizzle rethrows its own + * error with the driver error on `cause`. + * + * `errno` is the SQLSTATE (`23505`, `23503`, `23001`, …). `code` is NOT — Bun + * sets it to its own string (`ERR_POSTGRES_SERVER_ERROR`), so a SQLSTATE check + * against `code` never matches. `constraint` names the violated constraint. + */ +export type PostgresErrorNode = { + cause?: unknown; + code?: unknown; + constraint?: unknown; + errno?: unknown; +}; + +/** SQLSTATE for a unique-constraint violation. */ +export const UNIQUE_VIOLATION = '23505'; + +/** + * Whether any node in an error's cause chain satisfies `match`. + * + * A constraint violation always arrives wrapped, so the check has to walk the + * chain rather than inspect the thrown object. + */ +export const someErrorCause = ( + error: unknown, + match: (node: PostgresErrorNode) => boolean +): boolean => { + let current: unknown = error; + + while (current !== null && typeof current === 'object') { + if (match(current as PostgresErrorNode)) { + return true; + } + + current = (current as PostgresErrorNode).cause; + } + + return false; +}; + +/** Whether any node in the chain is a Postgres unique-constraint violation. */ +export const isUniqueViolation = (error: unknown): boolean => + someErrorCause(error, (node) => node.errno === UNIQUE_VIOLATION); diff --git a/apps/chronos/src/utils/security.ts b/apps/chronos/src/utils/security.ts new file mode 100644 index 00000000..d323c482 --- /dev/null +++ b/apps/chronos/src/utils/security.ts @@ -0,0 +1,175 @@ +/** + * The HTTP security policy: the response headers every answer carries, the CORS + * allow-list, the trusted client address, and the request-body ceiling. + * + * One module because each is a decision about what to trust. The transport in + * `#index` applies `withSecurityHeaders` to every response it returns (the + * better-auth surface, the RPC and OpenAPI handlers, the unsubscribe pages and + * the door-lock WebSocket handshake) and builds its handlers from `corsPlugins` + * and its request context from `resolveClientIp`, so a header, an allowed + * origin, a forwarding header or the body ceiling changes here and nowhere else. + */ +import { CORSPlugin, ResponseHeadersPlugin } from '@orpc/server/plugins'; +import type { ChronosContext } from '#_types/globals'; +// Type-only: importing the socket module for real would drag `#index` in and +// close a cycle back onto this file. +import type { DeviceSocketData } from '#modules/doorlock/device-socket'; +import { env } from '#utils/environment'; + +/** + * The directive set every response carries. + * + * `style-src`/`style-src-elem` allow `'unsafe-inline'` because the + * `/api/notifications/unsubscribe` pages ship their CSS in a `